Compare commits

..

1 Commits

Author SHA1 Message Date
opencode-agent[bot] 1dc54945d5 fix(tui): sync durable session model changes 2026-07-07 22:50:17 +00:00
265 changed files with 8541 additions and 11215 deletions
@@ -8,7 +8,7 @@ import type {
QuestionRequest,
Session,
SessionStatus,
FileDiffInfo,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
@@ -188,7 +188,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
break
}
@@ -5,7 +5,7 @@ import type {
PermissionRequest,
QuestionRequest,
SessionStatus,
FileDiffInfo,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -33,7 +33,7 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
@@ -67,7 +67,7 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
@@ -4,7 +4,7 @@ import type {
PermissionRequest,
QuestionRequest,
SessionStatus,
FileDiffInfo,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
@@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
@@ -13,7 +13,7 @@ import type {
ReferenceInfo,
Session,
SessionStatus,
FileDiffInfo,
SnapshotFileDiff,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
@@ -51,7 +51,7 @@ export type State = {
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: FileDiffInfo[]
[sessionID: string]: SnapshotFileDiff[]
}
todo: {
[sessionID: string]: Todo[]
+3 -3
View File
@@ -8,7 +8,7 @@ import type {
QuestionRequest,
Session,
SessionStatus,
FileDiffInfo,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
@@ -139,7 +139,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, FileDiffInfo[]>,
session_diff: {} as Record<string, SnapshotFileDiff[]>,
todo: {} as Record<string, Todo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
@@ -769,7 +769,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
return
}
@@ -1,6 +1,6 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type {
SessionReviewCommentActions,
@@ -14,7 +14,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = FileDiffInfo | VcsFileDiff
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps {
title?: JSX.Element
@@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -23,6 +23,7 @@ import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import {
@@ -35,9 +36,15 @@ import {
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => (FileDiffInfo | VcsFileDiff)[]
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
@@ -52,6 +59,7 @@ export function SessionSidePanel(props: {
}) {
const layout = useLayout()
const settings = useSettings()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
@@ -80,7 +88,7 @@ export function SessionSidePanel(props: {
})
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
const diffs = createMemo(() => props.diffs())
const diffs = createMemo(() => props.diffs().filter(renderDiff))
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
@@ -1,13 +1,17 @@
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { Kind } from "@/components/file-tree-v2"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
export type RenderDiff = FileDiffInfo | VcsFileDiff
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
export function normalizePath(p: string) {
return normalizeFileTreeV2Path(p)
}
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
return typeof value.file === "string"
}
export function reviewDiffKinds(diffs: RenderDiff[]) {
const merge = (a: Kind | undefined, b: Kind) => {
if (!a) return b
@@ -1,5 +1,5 @@
import { createMemo, createSignal, Show, type JSX } from "solid-js"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
@@ -21,11 +21,16 @@ import type {
import FileTreeV2 from "@/components/file-tree-v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { filterReviewFiles, reviewDiffKinds, type RenderDiff } from "@/pages/session/v2/review-diff-kinds"
import {
filterRenderableDiff,
filterReviewFiles,
reviewDiffKinds,
type RenderDiff,
} from "@/pages/session/v2/review-diff-kinds"
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = FileDiffInfo | VcsFileDiff
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
export type ReviewPanelV2Props = {
title?: JSX.Element
@@ -49,7 +54,7 @@ export type ReviewPanelV2Props = {
export function ReviewPanelV2(props: ReviewPanelV2Props) {
const sdk = useSDK()
const diffs = createMemo(() => props.diffs())
const diffs = createMemo(() => props.diffs().filter(filterRenderableDiff))
const filteredFiles = createMemo(() =>
filterReviewFiles(
diffs().map((diff) => diff.file),
+2 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs"
@@ -9,7 +9,7 @@ const item = {
additions: 1,
deletions: 1,
status: "modified",
} satisfies FileDiffInfo
} satisfies SnapshotFileDiff
describe("diffs", () => {
test("keeps valid arrays", () => {
+2 -2
View File
@@ -1,7 +1,7 @@
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
type Diff = FileDiffInfo
type Diff = SnapshotFileDiff | VcsFileDiff
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
+2 -3
View File
@@ -37,8 +37,7 @@ function defaultCost(model: CurrentModel) {
export function runAgent(input: CurrentAgent): RunAgent {
return {
id: input.id,
name: input.name,
name: input.id,
description: input.description,
mode: input.mode,
hidden: input.hidden,
@@ -54,7 +53,7 @@ export function runCommand(input: CurrentCommand): RunCommand {
export function runSkill(input: CurrentSkill): RunCommand {
return {
name: input.id,
name: input.name,
description: input.description,
source: "skill",
}
+2 -2
View File
@@ -333,10 +333,10 @@ export function createPromptState(input: PromptInput): PromptState {
.map((item) => ({
kind: "mention",
display: "@" + item.name,
value: item.id,
value: item.name,
part: {
type: "agent",
name: item.id,
name: item.name,
source: {
start: 0,
end: 0,
+1
View File
@@ -281,6 +281,7 @@ export async function runNonInteractivePrompt(input: Input) {
metadata: {
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
providerCall: current.provider,
providerResult: { executed: event.data.executed, state: event.data.resultState },
+8 -5
View File
@@ -89,11 +89,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
!explicitModel && !sessionModel
? await client.model
.default({ location: { directory: cwd, workspace } })
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
.then((result) =>
result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined,
)
: undefined
const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel)
if (input.variant && !model)
return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (input.variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
const available = await client.model.list({ location: { directory: cwd, workspace } })
@@ -111,7 +112,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, transport: Tr
if (!session && input.title !== undefined) {
await client.session.rename({
sessionID: selected.id,
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
title:
input.title ||
prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
})
}
@@ -197,7 +200,7 @@ async function validateAgent(client: OpenCodeClient, directory: string, name?: s
warning(`failed to list agents${server ? ` from ${server}` : ""}. Falling back to default agent`)
return
}
const agent = agents.find((item) => item.id === name)
const agent = agents.find((item) => item.name === name)
if (!agent) {
warning(`agent "${name}" not found. Falling back to default agent`)
return
+8 -6
View File
@@ -16,7 +16,7 @@
// backgrounding is intentionally absent: subagent jobs block the parent
// session, so only whole-session `v2.session.background(parentID)` exists.
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2"
import type { SessionMessage, SessionMessageAssistantTool, ToolPart } from "@opencode-ai/sdk/v2"
import { Locale } from "@opencode-ai/tui/util/locale"
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
@@ -54,7 +54,7 @@ export function legacyTool(input: {
callID: tool.id,
tool: tool.name,
}
if (tool.state.status === "streaming") {
if (tool.state.status === "pending") {
return {
...base,
state: { status: "pending", input: {}, raw: tool.state.input },
@@ -83,6 +83,7 @@ export function legacyTool(input: {
metadata: {
structured: tool.state.structured,
content: tool.state.content,
outputPaths: tool.state.outputPaths,
result: tool.state.result,
providerCall,
providerResult,
@@ -178,7 +179,7 @@ export type SubagentTrackerInput = {
export type SubagentTracker = {
main(event: V2Event): void
foreign(sessionID: string, event: V2Event): void
hydrate(next: { messages: SessionMessageInfo[]; active: Record<string, unknown> }): Promise<void>
hydrate(next: { messages: SessionMessage[]; active: Record<string, unknown> }): Promise<void>
select(sessionID: string | undefined): void
snapshot(): FooterSubagentState
}
@@ -315,7 +316,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
messageID,
tool: item,
})
if (item.state.status === "streaming") return
if (item.state.status === "pending") return
child.callIDs.add(item.id)
if (item.state.status === "running") {
setFrame(child, `tool:${item.id}`, toolCommit(part, "start"))
@@ -326,7 +327,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
setFrame(child, `tool:${item.id}`, toolCommit(part, "final"))
}
const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => {
const rebuild = (child: ChildState, messages: SessionMessage[]) => {
child.frames = []
child.text.clear()
child.projectedText.clear()
@@ -411,7 +412,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
for (const [id, prompt] of pendingPrompts) {
if (!child.prompts.has(id)) child.prompts.set(id, prompt)
}
rebuild(child, structuredClone(response.data).toReversed() as SessionMessageInfo[])
rebuild(child, structuredClone(response.data).toReversed() as SessionMessage[])
for (const [id, tool] of pendingTools) {
if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool)
}
@@ -621,6 +622,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
input: current?.input ?? {},
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
},
time: {
+16 -15
View File
@@ -2,7 +2,7 @@ import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/p
import type {
PermissionRequest,
QuestionRequest,
SessionMessageInfo,
SessionMessage,
SessionMessageAssistantTool,
} from "@opencode-ai/sdk/v2"
import { Event } from "@opencode-ai/schema/event"
@@ -389,7 +389,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
messageID,
tool: item,
})
if (item.state.status === "streaming") return
if (item.state.status === "pending") return
if (item.state.status === "running") {
if (state.tools.get(item.id)?.running) return
state.tools.set(item.id, {
@@ -417,7 +417,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
])
}
const renderMessage = (message: SessionMessageInfo, render: boolean, reuseVisibleWait: boolean) => {
const renderMessage = (message: SessionMessage, render: boolean, reuseVisibleWait: boolean) => {
if (message.type === "user") {
const waiting = state.wait?.messageID === message.id
if (waiting && state.wait) state.wait.promoted = true
@@ -438,34 +438,34 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
return
}
if (message.type === "shell") {
state.shellCommands.set(message.shellID, message.command)
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shellID
state.shellCommands.set(message.shell.id, message.shell.command)
if (state.shellWait?.messageID === message.id) state.shellWait.callID = message.shell.id
const completed = message.time.completed !== undefined
if (!render) {
// Suppressed history: mark settled shells rendered so live redelivery
// stays silent. A still-running shell stays unmarked and renders in
// full when its live shell.ended event arrives.
if (completed) {
state.shellStarted.add(message.shellID)
state.shellEnded.add(message.shellID)
state.shellStarted.add(message.shell.id)
state.shellEnded.add(message.shell.id)
}
return
}
if (!state.shellStarted.has(message.shellID)) {
state.shellStarted.add(message.shellID)
if (!state.shellStarted.has(message.shell.id)) {
state.shellStarted.add(message.shell.id)
write([
shellCommit(message.shellID, message.command, {
shellCommit(message.shell.id, message.shell.command, {
text: "running shell",
phase: "start",
toolState: "running",
}),
])
}
if (completed && message.output && !state.shellEnded.has(message.shellID)) {
state.shellEnded.add(message.shellID)
write(shellTerminal(message.shellID, message.command, message, message.output))
if (completed && message.output && !state.shellEnded.has(message.shell.id)) {
state.shellEnded.add(message.shell.id)
write(shellTerminal(message.shell.id, message.shell.command, message.shell, message.output))
}
if (completed && state.shellWait?.callID === message.shellID) state.shellWait.resolve()
if (completed && state.shellWait?.callID === message.shell.id) state.shellWait.resolve()
return
}
if (message.type !== "assistant") return
@@ -534,7 +534,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input.sdk.question.list({ sessionID: input.sessionID }),
input.sdk.session.active(),
])
const projected = structuredClone(messages.data).toReversed() as SessionMessageInfo[]
const projected = structuredClone(messages.data).toReversed() as SessionMessage[]
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
state.permissions = permissions.map(permission)
state.questions = questions.map(question)
@@ -762,6 +762,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input: current?.input ?? {},
structured: event.data.structured,
content: event.data.content,
outputPaths: event.data.outputPaths,
result: event.data.result,
},
time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created },
-1
View File
@@ -113,7 +113,6 @@ export type FooterQueuedPrompt = {
}
export type RunAgent = {
id: string
name: string
description?: string
mode: "subagent" | "primary" | "all"
+3 -2
View File
@@ -3,14 +3,15 @@ import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from
import {
ClientApi,
effectOmitEndpoints,
endpointNames,
groupNames,
promiseOmitEndpoints,
} from "@opencode-ai/protocol/client"
import { Effect } from "effect"
import { fileURLToPath } from "url"
const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints })
const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints })
await Effect.runPromise(
Effect.all(
+1
View File
@@ -1,6 +1,7 @@
export {
ClientApi,
effectOmitEndpoints,
endpointNames,
groupNames,
promiseOmitEndpoints,
} from "@opencode-ai/protocol/client"
+24 -30
View File
@@ -291,11 +291,9 @@ export interface SessionApi<E = never> {
readonly shell: SessionShellOperation<E>
readonly compact: SessionCompactOperation<E>
readonly wait: SessionWaitOperation<E>
readonly revert: {
readonly stage: SessionRevertStageOperation<E>
readonly clear: SessionRevertClearOperation<E>
readonly commit: SessionRevertCommitOperation<E>
}
readonly revertStage: SessionRevertStageOperation<E>
readonly revertClear: SessionRevertClearOperation<E>
readonly revertCommit: SessionRevertCommitOperation<E>
readonly context: SessionContextOperation<E>
readonly instructions: {
readonly entry: {
@@ -440,15 +438,11 @@ export type IntegrationAttemptCancelOperation<E = never> = (
export interface IntegrationApi<E = never> {
readonly list: IntegrationListOperation<E>
readonly get: IntegrationGetOperation<E>
readonly connect: {
readonly key: IntegrationConnectKeyOperation<E>
readonly oauth: IntegrationConnectOauthOperation<E>
}
readonly attempt: {
readonly status: IntegrationAttemptStatusOperation<E>
readonly complete: IntegrationAttemptCompleteOperation<E>
readonly cancel: IntegrationAttemptCancelOperation<E>
}
readonly connectKey: IntegrationConnectKeyOperation<E>
readonly connectOauth: IntegrationConnectOauthOperation<E>
readonly attemptStatus: IntegrationAttemptStatusOperation<E>
readonly attemptComplete: IntegrationAttemptCompleteOperation<E>
readonly attemptCancel: IntegrationAttemptCancelOperation<E>
}
type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
@@ -459,13 +453,11 @@ export type ServerMcpListOperation<E = never> = (input?: Endpoint10_0Input) => E
type Endpoint10_1Request = Parameters<RawClient["server.mcp"]["mcp.resource.catalog"]>[0]
export type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["location"] }
export type Endpoint10_1Output = EffectValue<ReturnType<RawClient["server.mcp"]["mcp.resource.catalog"]>>
export type ServerMcpResourceCatalogOperation<E = never> = (
input?: Endpoint10_1Input,
) => Effect.Effect<Endpoint10_1Output, E>
export type ServerMcpCatalogOperation<E = never> = (input?: Endpoint10_1Input) => Effect.Effect<Endpoint10_1Output, E>
export interface ServerMcpApi<E = never> {
readonly list: ServerMcpListOperation<E>
readonly resource: { readonly catalog: ServerMcpResourceCatalogOperation<E> }
readonly catalog: ServerMcpCatalogOperation<E>
}
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
@@ -515,7 +507,7 @@ export interface ProjectApi<E = never> {
type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]
export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
export type Endpoint13_0Output = EffectValue<ReturnType<RawClient["server.form"]["form.request.list"]>>
export type FormRequestListOperation<E = never> = (input?: Endpoint13_0Input) => Effect.Effect<Endpoint13_0Output, E>
export type FormListRequestsOperation<E = never> = (input?: Endpoint13_0Input) => Effect.Effect<Endpoint13_0Output, E>
type Endpoint13_1Request = Parameters<RawClient["server.form"]["session.form.list"]>[0]
export type Endpoint13_1Input = { readonly sessionID: Endpoint13_1Request["params"]["sessionID"] }
@@ -569,7 +561,7 @@ export type Endpoint13_6Output = EffectValue<ReturnType<RawClient["server.form"]
export type FormCancelOperation<E = never> = (input: Endpoint13_6Input) => Effect.Effect<Endpoint13_6Output, E>
export interface FormApi<E = never> {
readonly request: { readonly list: FormRequestListOperation<E> }
readonly listRequests: FormListRequestsOperation<E>
readonly list: FormListOperation<E>
readonly create: FormCreateOperation<E>
readonly get: FormGetOperation<E>
@@ -581,7 +573,7 @@ export interface FormApi<E = never> {
type Endpoint14_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
export type Endpoint14_0Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.request.list"]>>
export type PermissionRequestListOperation<E = never> = (
export type PermissionListRequestsOperation<E = never> = (
input?: Endpoint14_0Input,
) => Effect.Effect<Endpoint14_0Output, E>
@@ -590,14 +582,14 @@ export type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["quer
export type Endpoint14_1Output = EffectValue<
ReturnType<RawClient["server.permission"]["permission.saved.list"]>
>["data"]
export type PermissionSavedListOperation<E = never> = (
export type PermissionListSavedOperation<E = never> = (
input?: Endpoint14_1Input,
) => Effect.Effect<Endpoint14_1Output, E>
type Endpoint14_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] }
export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.saved.remove"]>>
export type PermissionSavedRemoveOperation<E = never> = (
export type PermissionRemoveSavedOperation<E = never> = (
input: Endpoint14_2Input,
) => Effect.Effect<Endpoint14_2Output, E>
@@ -645,8 +637,9 @@ export type Endpoint14_6Output = EffectValue<ReturnType<RawClient["server.permis
export type PermissionReplyOperation<E = never> = (input: Endpoint14_6Input) => Effect.Effect<Endpoint14_6Output, E>
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
readonly listRequests: PermissionListRequestsOperation<E>
readonly listSaved: PermissionListSavedOperation<E>
readonly removeSaved: PermissionRemoveSavedOperation<E>
readonly create: PermissionCreateOperation<E>
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
@@ -815,7 +808,7 @@ export interface ShellApi<E = never> {
type Endpoint21_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] }
export type Endpoint21_0Output = EffectValue<ReturnType<RawClient["server.question"]["question.request.list"]>>
export type QuestionRequestListOperation<E = never> = (
export type QuestionListRequestsOperation<E = never> = (
input?: Endpoint21_0Input,
) => Effect.Effect<Endpoint21_0Output, E>
@@ -842,7 +835,7 @@ export type Endpoint21_3Output = EffectValue<ReturnType<RawClient["server.questi
export type QuestionRejectOperation<E = never> = (input: Endpoint21_3Input) => Effect.Effect<Endpoint21_3Output, E>
export interface QuestionApi<E = never> {
readonly request: { readonly list: QuestionRequestListOperation<E> }
readonly listRequests: QuestionListRequestsOperation<E>
readonly list: QuestionListOperation<E>
readonly reply: QuestionReplyOperation<E>
readonly reject: QuestionRejectOperation<E>
@@ -912,15 +905,16 @@ export interface VcsApi<E = never> {
}
export type Endpoint25_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
export type DebugLocationOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
type Endpoint25_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
export type Endpoint25_1Input = { readonly location?: Endpoint25_1Request["query"]["location"] }
export type Endpoint25_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>>
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export type DebugEvictLocationOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
readonly location: DebugLocationOperation<E>
readonly evictLocation: DebugEvictLocationOperation<E>
}
export interface AppApi<E = never> {
+16 -12
View File
@@ -381,7 +381,9 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
shell: Endpoint4_14(raw),
compact: Endpoint4_15(raw),
wait: Endpoint4_16(raw),
revert: { stage: Endpoint4_17(raw), clear: Endpoint4_18(raw), commit: Endpoint4_19(raw) },
revertStage: Endpoint4_17(raw),
revertClear: Endpoint4_18(raw),
revertCommit: Endpoint4_19(raw),
context: Endpoint4_20(raw),
instructions: { entry: { list: Endpoint4_21(raw), put: Endpoint4_22(raw), remove: Endpoint4_23(raw) } },
log: Endpoint4_24(raw),
@@ -534,8 +536,11 @@ const Endpoint9_6 = (raw: RawClient["server.integration"]) => (input: Endpoint9_
const adaptGroup9 = (raw: RawClient["server.integration"]) => ({
list: Endpoint9_0(raw),
get: Endpoint9_1(raw),
connect: { key: Endpoint9_2(raw), oauth: Endpoint9_3(raw) },
attempt: { status: Endpoint9_4(raw), complete: Endpoint9_5(raw), cancel: Endpoint9_6(raw) },
connectKey: Endpoint9_2(raw),
connectOauth: Endpoint9_3(raw),
attemptStatus: Endpoint9_4(raw),
attemptComplete: Endpoint9_5(raw),
attemptCancel: Endpoint9_6(raw),
})
type Endpoint10_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
@@ -548,10 +553,7 @@ type Endpoint10_1Input = { readonly location?: Endpoint10_1Request["query"]["loc
const Endpoint10_1 = (raw: RawClient["server.mcp"]) => (input?: Endpoint10_1Input) =>
raw["mcp.resource.catalog"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({
list: Endpoint10_0(raw),
resource: { catalog: Endpoint10_1(raw) },
})
const adaptGroup10 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint10_0(raw), catalog: Endpoint10_1(raw) })
type Endpoint11_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint11_0Input = {
@@ -688,7 +690,7 @@ const Endpoint13_6 = (raw: RawClient["server.form"]) => (input: Endpoint13_6Inpu
)
const adaptGroup13 = (raw: RawClient["server.form"]) => ({
request: { list: Endpoint13_0(raw) },
listRequests: Endpoint13_0(raw),
list: Endpoint13_1(raw),
create: Endpoint13_2(raw),
get: Endpoint13_3(raw),
@@ -776,8 +778,9 @@ const Endpoint14_6 = (raw: RawClient["server.permission"]) => (input: Endpoint14
}).pipe(Effect.mapError(mapClientError))
const adaptGroup14 = (raw: RawClient["server.permission"]) => ({
request: { list: Endpoint14_0(raw) },
saved: { list: Endpoint14_1(raw), remove: Endpoint14_2(raw) },
listRequests: Endpoint14_0(raw),
listSaved: Endpoint14_1(raw),
removeSaved: Endpoint14_2(raw),
create: Endpoint14_3(raw),
list: Endpoint14_4(raw),
get: Endpoint14_5(raw),
@@ -1010,7 +1013,7 @@ const Endpoint21_3 = (raw: RawClient["server.question"]) => (input: Endpoint21_3
)
const adaptGroup21 = (raw: RawClient["server.question"]) => ({
request: { list: Endpoint21_0(raw) },
listRequests: Endpoint21_0(raw),
list: Endpoint21_1(raw),
reply: Endpoint21_2(raw),
reject: Endpoint21_3(raw),
@@ -1096,7 +1099,8 @@ const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1In
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
location: Endpoint25_0(raw),
evictLocation: Endpoint25_1(raw),
})
const adaptClient = (raw: RawClient) => ({
+206 -224
View File
@@ -89,8 +89,8 @@ import type {
IntegrationAttemptCancelOutput,
ServerMcpListInput,
ServerMcpListOutput,
ServerMcpResourceCatalogInput,
ServerMcpResourceCatalogOutput,
ServerMcpCatalogInput,
ServerMcpCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialRemoveInput,
@@ -100,8 +100,8 @@ import type {
ProjectCurrentOutput,
ProjectDirectoriesInput,
ProjectDirectoriesOutput,
FormRequestListInput,
FormRequestListOutput,
FormListRequestsInput,
FormListRequestsOutput,
FormListInput,
FormListOutput,
FormCreateInput,
@@ -114,12 +114,12 @@ import type {
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionSavedListInput,
PermissionSavedListOutput,
PermissionSavedRemoveInput,
PermissionSavedRemoveOutput,
PermissionListRequestsInput,
PermissionListRequestsOutput,
PermissionListSavedInput,
PermissionListSavedOutput,
PermissionRemoveSavedInput,
PermissionRemoveSavedOutput,
PermissionCreateInput,
PermissionCreateOutput,
PermissionListInput,
@@ -161,8 +161,8 @@ import type {
ShellOutputOutput,
ShellRemoveInput,
ShellRemoveOutput,
QuestionRequestListInput,
QuestionRequestListOutput,
QuestionListRequestsInput,
QuestionListRequestsOutput,
QuestionListInput,
QuestionListOutput,
QuestionReplyInput,
@@ -181,9 +181,9 @@ import type {
VcsStatusOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
DebugLocationEvictInput,
DebugLocationEvictOutput,
DebugLocationOutput,
DebugEvictLocationInput,
DebugEvictLocationOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -601,42 +601,40 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
revert: {
stage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionRevertStageOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
body: { messageID: input["messageID"], files: input["files"] },
successStatus: 200,
declaredStatuses: [404, 409, 500, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
clear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) =>
request<SessionRevertClearOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
successStatus: 204,
declaredStatuses: [404, 409, 500, 400, 401],
empty: true,
},
requestOptions,
),
commit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) =>
request<SessionRevertCommitOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
successStatus: 204,
declaredStatuses: [404, 409, 400, 401],
empty: true,
},
requestOptions,
),
},
revertStage: (input: SessionRevertStageInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionRevertStageOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
body: { messageID: input["messageID"], files: input["files"] },
successStatus: 200,
declaredStatuses: [404, 409, 500, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
revertClear: (input: SessionRevertClearInput, requestOptions?: RequestOptions) =>
request<SessionRevertClearOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
successStatus: 204,
declaredStatuses: [404, 409, 500, 400, 401],
empty: true,
},
requestOptions,
),
revertCommit: (input: SessionRevertCommitInput, requestOptions?: RequestOptions) =>
request<SessionRevertCommitOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
successStatus: 204,
declaredStatuses: [404, 409, 400, 401],
empty: true,
},
requestOptions,
),
context: (input: SessionContextInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionContextOutput }>(
{
@@ -838,73 +836,69 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
connect: {
key: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
request<IntegrationConnectKeyOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
query: { location: input["location"] },
body: { key: input["key"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
oauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) =>
request<IntegrationConnectOauthOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
query: { location: input["location"] },
body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
attempt: {
status: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) =>
request<IntegrationAttemptStatusOutput>(
{
method: "GET",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
complete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) =>
request<IntegrationAttemptCompleteOutput>(
{
method: "POST",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
query: { location: input["location"] },
body: { code: input["code"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
cancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) =>
request<IntegrationAttemptCancelOutput>(
{
method: "DELETE",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
},
connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
request<IntegrationConnectKeyOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
query: { location: input["location"] },
body: { key: input["key"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
connectOauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) =>
request<IntegrationConnectOauthOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
query: { location: input["location"] },
body: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
attemptStatus: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) =>
request<IntegrationAttemptStatusOutput>(
{
method: "GET",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
attemptComplete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) =>
request<IntegrationAttemptCompleteOutput>(
{
method: "POST",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
query: { location: input["location"] },
body: { code: input["code"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
attemptCancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) =>
request<IntegrationAttemptCancelOutput>(
{
method: "DELETE",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
},
"server.mcp": {
list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) =>
@@ -919,20 +913,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
resource: {
catalog: (input?: ServerMcpResourceCatalogInput, requestOptions?: RequestOptions) =>
request<ServerMcpResourceCatalogOutput>(
{
method: "GET",
path: `/api/mcp/resource`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
catalog: (input?: ServerMcpCatalogInput, requestOptions?: RequestOptions) =>
request<ServerMcpCatalogOutput>(
{
method: "GET",
path: `/api/mcp/resource`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
credential: {
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
@@ -993,20 +985,18 @@ export function make(options: ClientOptions) {
),
},
form: {
request: {
list: (input?: FormRequestListInput, requestOptions?: RequestOptions) =>
request<FormRequestListOutput>(
{
method: "GET",
path: `/api/form/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
listRequests: (input?: FormListRequestsInput, requestOptions?: RequestOptions) =>
request<FormListRequestsOutput>(
{
method: "GET",
path: `/api/form/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
list: (input: FormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormListOutput }>(
{
@@ -1084,45 +1074,41 @@ export function make(options: ClientOptions) {
),
},
permission: {
request: {
list: (input?: PermissionRequestListInput, requestOptions?: RequestOptions) =>
request<PermissionRequestListOutput>(
{
method: "GET",
path: `/api/permission/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
saved: {
list: (input?: PermissionSavedListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionSavedListOutput }>(
{
method: "GET",
path: `/api/permission/saved`,
query: { projectID: input?.["projectID"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: PermissionSavedRemoveInput, requestOptions?: RequestOptions) =>
request<PermissionSavedRemoveOutput>(
{
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
},
listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) =>
request<PermissionListRequestsOutput>(
{
method: "GET",
path: `/api/permission/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
listSaved: (input?: PermissionListSavedInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionListSavedOutput }>(
{
method: "GET",
path: `/api/permission/saved`,
query: { projectID: input?.["projectID"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
removeSaved: (input: PermissionRemoveSavedInput, requestOptions?: RequestOptions) =>
request<PermissionRemoveSavedOutput>(
{
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
create: (input: PermissionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionCreateOutput }>(
{
@@ -1404,20 +1390,18 @@ export function make(options: ClientOptions) {
),
},
question: {
request: {
list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) =>
request<QuestionRequestListOutput>(
{
method: "GET",
path: `/api/question/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
listRequests: (input?: QuestionListRequestsInput, requestOptions?: RequestOptions) =>
request<QuestionListRequestsOutput>(
{
method: "GET",
path: `/api/question/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: QuestionListOutput }>(
{
@@ -1534,31 +1518,29 @@ export function make(options: ClientOptions) {
),
},
debug: {
location: {
list: (requestOptions?: RequestOptions) =>
request<DebugLocationListOutput>(
{
method: "GET",
path: `/api/debug/location`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
evict: (input?: DebugLocationEvictInput, requestOptions?: RequestOptions) =>
request<DebugLocationEvictOutput>(
{
method: "DELETE",
path: `/api/debug/location`,
query: { location: input?.["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
},
location: (requestOptions?: RequestOptions) =>
request<DebugLocationOutput>(
{
method: "GET",
path: `/api/debug/location`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
evictLocation: (input?: DebugEvictLocationInput, requestOptions?: RequestOptions) =>
request<DebugEvictLocationOutput>(
{
method: "DELETE",
path: `/api/debug/location`,
query: { location: input?.["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
},
}
}
File diff suppressed because it is too large Load Diff
@@ -19,7 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
import { ClientApi, groupNames, promiseOmitEndpoints } from "../src/contract"
import { ClientApi, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract"
const Client = await import("../src/effect")
@@ -38,7 +38,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
expect(ProjectV2.Directory).toBe(Project.Directory)
expect(ProjectV2.Directories).toBe(Project.Directories)
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
expect(CoreSessionMessage.Info).toBe(SessionMessage.Info)
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
expect(Api.groups["server.session"].identifier).toBe("server.session")
expect(Api.groups["server.project"].identifier).toBe("server.project")
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
@@ -49,8 +49,8 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
})
test("client and Server contracts generate identically", () => {
const server = compile(Api, { groupNames, omitEndpoints: promiseOmitEndpoints })
const client = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
const server = compile(Api, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
expect(emitPromise(client)).toEqual(emitPromise(server))
})
+11 -6
View File
@@ -32,12 +32,17 @@ test("exposes every standard HTTP API group", () => {
"vcs",
"debug",
])
expect(Object.keys(client.debug)).toEqual(["location"])
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
expect(Object.keys(client.debug)).toEqual(["location", "evictLocation"])
expect(Object.keys(client.message)).toEqual(["list"])
expect(Object.keys(client.integration)).toEqual(["list", "get", "connect", "attempt"])
expect(Object.keys(client.integration.connect)).toEqual(["key", "oauth"])
expect(Object.keys(client.integration.attempt)).toEqual(["status", "complete", "cancel"])
expect(Object.keys(client.integration)).toEqual([
"list",
"get",
"connectKey",
"connectOauth",
"attemptStatus",
"attemptComplete",
"attemptCancel",
])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
@@ -61,7 +66,7 @@ test("MCP resource catalog uses the public HTTP contract", async () => {
},
})
const result = await client["server.mcp"].resource.catalog({ location: { directory: "/tmp/project" } })
const result = await client["server.mcp"].catalog({ location: { directory: "/tmp/project" } })
expect(result.data.resources[0]?.uri).toBe("docs://readme")
expect(request?.method).toBe("GET")
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -177,13 +177,13 @@ describe("OpenAPI.fromSpec", () => {
const spec = await opencodeSpec()
const result = OpenAPI.fromSpec({ spec, baseUrl })
expect(result.skipped).toHaveLength(4)
expect(result.skipped).toHaveLength(5)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/pty/{ptyID}/connect",
reason: "WebSocket operations are not supported",
})
expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2)
expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(3)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/fs/read/*",
@@ -210,11 +210,11 @@ describe("OpenAPI.fromSpec", () => {
if (!Tool.isDefinition(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
expect(Tool.isDefinition(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
expect(toolAt(result.tools, "v2.pty.connect")).toBeUndefined()
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
expect(toolAt(result.tools, "v2.pty.connect.token")).not.toBeUndefined()
expect(toolAt(result.tools, "v2.pty.connectToken")).not.toBeUndefined()
})
test("preserves operation path sanitization and collision handling", () => {
-2
View File
@@ -8,8 +8,6 @@ import { State } from "./state"
export const ID = Agent.ID
export type ID = typeof ID.Type
export const Name = Agent.Name
export type Name = Agent.Name
export const defaultID = ID.make("build")
export const Color = Agent.Color
+1 -4
View File
@@ -305,7 +305,6 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
const route: AnyRoute = {
id: `ai-sdk:${ProviderV2.packageName(info.package) ?? "unknown"}`,
provider: ProviderID.make(info.providerID),
providerMetadataKey: optionKey,
protocol: "ai-sdk",
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
auth: Auth.none,
@@ -418,7 +417,7 @@ function assistantPart(part: ContentPart): AssistantContent {
case "media":
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
case "reasoning":
return [{ type: "reasoning", text: part.text, providerOptions: providerOptions(part.providerMetadata) }]
return [{ type: "reasoning", text: part.text }]
case "tool-call":
return [
{
@@ -427,7 +426,6 @@ function assistantPart(part: ContentPart): AssistantContent {
toolName: part.name,
input: part.input,
providerExecuted: part.providerExecuted,
providerOptions: providerOptions(part.providerMetadata),
},
]
case "tool-result":
@@ -443,7 +441,6 @@ function toolResultPart(part: ContentPart): ToolResultContent[] {
toolCallId: part.id,
toolName: part.name,
output: toolOutput(part.result),
providerOptions: providerOptions(part.providerMetadata),
},
]
}
+2 -3
View File
@@ -1,7 +1,6 @@
export * as ConfigProviderPlugin from "./provider"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
@@ -92,8 +91,8 @@ export const Plugin = define({
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
+4 -5
View File
@@ -1,7 +1,6 @@
export * as ConfigProvider from "./provider"
import { Schema } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { ModelV2 } from "../model"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
@@ -18,8 +17,8 @@ export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")(
}) {}
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
read: Money.USDPerMillionTokens.pipe(Schema.optional),
write: Money.USDPerMillionTokens.pipe(Schema.optional),
read: Schema.Finite.pipe(Schema.optional),
write: Schema.Finite.pipe(Schema.optional),
}) {}
class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
@@ -27,8 +26,8 @@ class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(Schema.optional),
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
input: Schema.Finite,
output: Schema.Finite,
cache: Cache.pipe(Schema.optional),
}) {}
-1
View File
@@ -48,6 +48,5 @@ export const migrations = (
import("./migration/20260705180000_rename_instructions"),
import("./migration/20260706223930_add-session-fork"),
import("./migration/20260707010146_durable_session_inbox"),
import("./migration/20260707120000_migrate_prelaunch_v2_state"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,227 +0,0 @@
import { sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import type { DatabaseMigration } from "../migration"
const decodeJson = Schema.decodeUnknownSync(Schema.UnknownFromJsonString)
const isObject = Schema.is(Schema.Record(Schema.String, Schema.Unknown))
export default {
id: "20260707120000_migrate_prelaunch_v2_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(
sql`DELETE FROM session_message WHERE type = 'compaction' AND json_extract(data, '$.status') = 'queued'`,
)
const messages = yield* tx.all<{ id: string; type: string; data: string }>(
sql`SELECT id, type, data FROM session_message WHERE type IN ('skill', 'shell', 'assistant', 'compaction', 'synthetic')`,
)
for (const row of messages) {
const data = object(decodeJson(row.data))
yield* tx.run(
sql`UPDATE session_message SET data = ${JSON.stringify(messageData(row.type, data))} WHERE id = ${row.id}`,
)
}
yield* tx.run(sql`DELETE FROM event WHERE type = 'session.compaction.delta.1'`)
const events = yield* tx.all<{ id: string; aggregateID: string; seq: number; type: string; data: string }>(sql`
SELECT id, aggregate_id as aggregateID, seq, type, data
FROM event
WHERE type IN (
'session.skill.activated.1',
'session.skill.activated.2',
'session.compaction.started.1',
'session.compaction.started.2',
'session.compaction.ended.1',
'session.compaction.failed.1',
'session.compaction.failed.2',
'session.revert.staged.1',
'session.revert.staged.2'
)
ORDER BY aggregate_id, seq
`)
const compactionReasons = new Map<string, "auto" | "manual">()
for (const row of events) {
const data = object(decodeJson(row.data))
if (row.type.startsWith("session.compaction.ended.")) {
compactionReasons.delete(row.aggregateID)
continue
}
const event = eventData(row.type, data, compactionReasons.get(row.aggregateID))
if (row.type.startsWith("session.compaction.started."))
compactionReasons.set(row.aggregateID, event.data.reason === "auto" ? "auto" : "manual")
if (row.type.startsWith("session.compaction.failed.")) compactionReasons.delete(row.aggregateID)
yield* tx.run(
sql`UPDATE event SET type = ${event.type}, data = ${JSON.stringify(event.data)} WHERE id = ${row.id}`,
)
}
})
},
} satisfies DatabaseMigration.Migration
function messageData(type: string, data: Record<string, unknown>) {
if (type === "skill")
return defined({
metadata: data.metadata,
time: data.time,
skill: data.skill ?? data.id ?? data.name,
name: data.name,
text: data.text,
})
if (type === "shell") {
const shell = object(data.shell)
return defined({
metadata: data.metadata,
time: data.time,
shellID: data.shellID ?? shell.id,
command: data.command ?? shell.command,
status: data.status ?? shell.status,
exit: data.exit ?? shell.exit,
output: data.output,
})
}
if (type === "assistant")
return defined({
metadata: data.metadata,
time: data.time,
agent: data.agent,
model: data.model,
content: Array.isArray(data.content) ? data.content.map(assistantContent) : data.content,
snapshot: data.snapshot,
finish: data.finish,
cost: data.cost,
tokens: data.tokens,
error: data.error,
retry: data.retry,
})
if (type === "compaction") {
if (data.status === "failed")
return defined({
metadata: data.metadata,
time: data.time,
status: data.status,
reason: data.reason,
error: data.error ?? genericCompactionError,
})
return defined({
metadata: data.metadata,
time: data.time,
status: data.status,
reason: data.reason,
summary: data.summary,
recent: data.recent,
})
}
if (type === "synthetic")
return defined({ metadata: data.metadata, time: data.time, text: data.text, description: data.description })
const { sessionID: _, ...current } = data
return current
}
function assistantContent(value: unknown) {
const content = object(value)
if (content.type === "text") return defined({ type: content.type, text: content.text })
if (content.type === "reasoning")
return defined({ type: content.type, text: content.text, state: content.state, time: content.time })
if (content.type !== "tool") return content
return defined({
type: content.type,
id: content.id,
name: content.name,
executed: content.executed,
providerState: content.providerState,
providerResultState: content.providerResultState,
state: toolState(content.state),
time: content.time,
})
}
function toolState(value: unknown) {
const state = object(value)
if (state.status === "pending" || state.status === "streaming")
return defined({ status: "streaming", input: state.input })
if (state.status === "running")
return defined({ status: state.status, input: state.input, structured: state.structured, content: state.content })
if (state.status === "completed")
return defined({
status: state.status,
input: state.input,
structured: state.structured,
content: state.content,
result: state.result,
})
if (state.status === "error")
return defined({
status: state.status,
input: state.input,
structured: state.structured,
content: state.content,
error: state.error,
result: state.result,
})
return state
}
function eventData(type: string, data: Record<string, unknown>, compactionReason?: "auto" | "manual") {
if (type.startsWith("session.skill.activated."))
return {
type: "session.skill.activated.1",
data: defined({ sessionID: data.sessionID, id: data.id ?? data.name, name: data.name, text: data.text }),
}
if (type.startsWith("session.compaction.started."))
return {
type: "session.compaction.started.1",
data: defined({
sessionID: data.sessionID,
reason: data.reason,
recent: data.recent ?? "",
inputID: data.inputID,
}),
}
if (type.startsWith("session.compaction.failed."))
return {
type: "session.compaction.failed.1",
data: defined({
sessionID: data.sessionID,
reason: data.reason ?? compactionReason ?? "manual",
error: data.error ?? genericCompactionError,
inputID: data.inputID,
}),
}
const revert = object(data.revert)
return {
type: "session.revert.staged.1",
data: defined({
sessionID: data.sessionID,
revert: defined({
messageID: revert.messageID,
partID: revert.partID,
snapshot: revert.snapshot,
files: Array.isArray(revert.files)
? revert.files.map((value) => {
const file = object(value)
return defined({
file: file.file ?? file.path,
patch: file.patch,
additions: file.additions,
deletions: file.deletions,
status: file.status,
})
})
: undefined,
}),
}),
}
}
const genericCompactionError = {
type: "compaction.failed",
message: "Compaction failed before recording an error",
}
function object(value: unknown): Record<string, unknown> {
return isObject(value) ? value : {}
}
function defined(value: Record<string, unknown>) {
return Object.fromEntries(Object.entries(value).filter((entry) => entry[1] !== undefined))
}
+2 -2
View File
@@ -1,6 +1,6 @@
export * as File from "./file"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { Revert } from "@opencode-ai/schema/revert"
export const Diff = FileDiff.Info
export const Diff = Revert.FileDiff
export type Diff = typeof Diff.Type
+1 -1
View File
@@ -606,7 +606,7 @@ const layer = Layer.effect(
file,
])).text
return {
file,
path: file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
+12 -13
View File
@@ -2,7 +2,6 @@ import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
import { Global } from "./global"
import { Flag } from "./flag/flag"
import { Flock } from "./util/flock"
@@ -19,10 +18,10 @@ export type CatalogModelStatus = typeof CatalogModelStatus.Type
const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
const CostTier = Schema.Struct({
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache_read: Schema.optional(Money.USDPerMillionTokens),
cache_write: Schema.optional(Money.USDPerMillionTokens),
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Finite,
@@ -30,17 +29,17 @@ const CostTier = Schema.Struct({
})
const Cost = Schema.Struct({
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache_read: Schema.optional(Money.USDPerMillionTokens),
cache_write: Schema.optional(Money.USDPerMillionTokens),
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
tiers: Schema.optional(Schema.Array(CostTier)),
context_over_200k: Schema.optional(
Schema.Struct({
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache_read: Schema.optional(Money.USDPerMillionTokens),
cache_write: Schema.optional(Money.USDPerMillionTokens),
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
}),
),
})
+6 -13
View File
@@ -1,6 +1,6 @@
export * as PluginV2 from "./plugin"
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Event, ID, type Info } from "@opencode-ai/schema/plugin"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Exit, Layer, Scope, Semaphore } from "effect"
@@ -18,7 +18,6 @@ import { SkillV2 } from "./skill"
import { State } from "./state"
import { ToolRegistry } from "./tool/registry"
import { ToolHooks } from "./tool/hooks"
import { PluginHooks } from "./plugin/hooks"
export interface Interface {
readonly activate: (plugins: readonly { readonly plugin: Plugin; readonly version?: string }[]) => Effect.Effect<void>
@@ -58,13 +57,12 @@ const layer = Layer.effect(
generation.length === definitions.length &&
generation.every(
(plugin, index) => plugin.id === definitions[index]?.id && plugin.version === definitions[index]?.version,
) &&
definitions.every((definition) => active.has(definition.id))
)
) {
return
}
generation = undefined
yield* State.batch(
const exit = yield* State.batch(
Effect.gen(function* () {
const scopes = Array.from(active.values()).toReversed()
active.clear()
@@ -83,17 +81,13 @@ const layer = Layer.effect(
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
if (Exit.isFailure(loaded)) {
yield* Effect.logWarning("failed to load plugin", {
"plugin.id": definition.id,
cause: loaded.cause,
})
continue
}
if (Exit.isFailure(loaded)) return loaded
active.set(definition.id, child)
}
return Exit.void
}),
)
if (Exit.isFailure(exit)) return yield* exit
generation = definitions.map((definition) => ({
id: definition.id,
...(definition.version === undefined ? {} : { version: definition.version }),
@@ -137,7 +131,6 @@ export const node = makeLocationNode({
SkillV2.node,
ToolRegistry.toolsNode,
ToolHooks.node,
PluginHooks.node,
PluginRuntime.node,
],
})
-7
View File
@@ -125,7 +125,6 @@ export const Plugin = define({
yield* ctx.agent.transform((draft) => {
draft.update(AgentV2.defaultID, (item) => {
item.name = AgentV2.Name.make("Build")
item.description = "The default agent. Executes tools based on configured permissions."
item.mode = "primary"
item.permissions.push(
@@ -137,7 +136,6 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("plan"), (item) => {
item.name = AgentV2.Name.make("Plan")
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
@@ -157,7 +155,6 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("general"), (item) => {
item.name = AgentV2.Name.make("General")
item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
item.mode = "subagent"
@@ -170,7 +167,6 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("explore"), (item) => {
item.name = AgentV2.Name.make("Explore")
item.description =
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
item.system = PROMPT_EXPLORE
@@ -193,7 +189,6 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("compaction"), (item) => {
item.name = AgentV2.Name.make("Compaction")
item.mode = "primary"
item.hidden = true
item.system = PROMPT_COMPACTION
@@ -201,7 +196,6 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("title"), (item) => {
item.name = AgentV2.Name.make("Title")
item.mode = "primary"
item.hidden = true
item.system = PROMPT_TITLE
@@ -209,7 +203,6 @@ export const Plugin = define({
})
draft.update(AgentV2.ID.make("summary"), (item) => {
item.name = AgentV2.Name.make("Summary")
item.mode = "primary"
item.hidden = true
item.system = PROMPT_SUMMARY
-67
View File
@@ -1,67 +0,0 @@
export * as PluginHooks from "./hooks"
import type { AISDKHooks } from "@opencode-ai/plugin/v2/effect/aisdk"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import type { ToolHooks } from "@opencode-ai/plugin/v2/effect/tool"
import { Context, Effect, Layer, Scope } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { State } from "../state"
export interface Domains {
readonly aisdk: AISDKHooks
readonly session: SessionHooks
readonly tool: ToolHooks
}
type Callback<Event> = (event: Event) => Effect.Effect<void>
export interface Interface {
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
domain: Domain,
name: Name,
callback: Callback<Domains[Domain][Name]>,
) => Effect.Effect<State.Registration, never, Scope.Scope>
readonly trigger: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
domain: Domain,
name: Name,
event: Domains[Domain][Name],
) => Effect.Effect<Domains[Domain][Name]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginHooks") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const callbacks = new Map<string, Function[]>()
const key = (domain: keyof Domains, name: PropertyKey) => `${domain}.${String(name)}`
const register: Interface["register"] = Effect.fn("PluginHooks.register")(function* (domain, name, callback) {
const scope = yield* Scope.Scope
const id = key(domain, name)
let active = true
callbacks.set(id, [...(callbacks.get(id) ?? []), callback])
const dispose = Effect.sync(() => {
if (!active) return
active = false
const next = (callbacks.get(id) ?? []).filter((item) => item !== callback)
if (next.length === 0) callbacks.delete(id)
else callbacks.set(id, next)
})
yield* Scope.addFinalizer(scope, dispose)
return { dispose }
})
const trigger: Interface["trigger"] = Effect.fn("PluginHooks.trigger")(function* (domain, name, event) {
for (const callback of callbacks.get(key(domain, name)) ?? []) {
const result: Effect.Effect<void> = Reflect.apply(callback, undefined, [event])
yield* result
}
return event
})
return Service.of({ register, trigger })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
+68 -74
View File
@@ -1,6 +1,6 @@
export * as PluginHost from "./host"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent"
@@ -22,7 +22,6 @@ import { Tool } from "../tool/tool"
import { Tools } from "../tool/tools"
import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace"
import { PluginHooks } from "./hooks"
const mutable = <T>(value: T) => value as DeepMutable<T>
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
@@ -37,7 +36,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
const skill = yield* SkillV2.Service
const tools = yield* Tools.Service
const toolHooks = yield* ToolHooks.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
const locationInfo = () =>
new Location.Info({
@@ -45,7 +43,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
workspaceID: location.workspaceID,
project: location.project,
})
const locationRef = (input?: Parameters<Plugin.Context["agent"]["list"]>[0]) =>
const locationRef = (input?: Parameters<PluginContext["agent"]["list"]>[0]) =>
input?.location === undefined
? undefined
: Location.Ref.make({
@@ -81,32 +79,32 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
},
aisdk: {
hook: (name, callback) => {
if (name === "sdk") {
return aisdk.hook.sdk((event) => {
const output = {
model: mutable(event.model),
package: event.package,
options: event.options,
sdk: event.sdk,
}
return Reflect.apply(callback, undefined, [output]).pipe(
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
)
})
}
return aisdk.hook.language((event) => {
sdk: (callback) =>
aisdk.hook.sdk((event) => {
const output = {
model: mutable(event.model),
package: event.package,
options: event.options,
sdk: event.sdk,
}
const result = callback(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.sdk = output.sdk))),
)
}),
language: (callback) =>
aisdk.hook.language((event) => {
const output = {
model: mutable(event.model),
sdk: event.sdk,
options: event.options,
language: event.language,
}
return Reflect.apply(callback, undefined, [output]).pipe(
const result = callback(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.language = output.language))),
)
})
},
}),
},
catalog: {
provider: {
@@ -166,29 +164,25 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
integration: {
list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
connect: {
key: (input) =>
integration.connection.key({
connectKey: (input) =>
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
key: input.key,
label: input.label,
}),
connectOauth: (input) =>
response(
integration.connection.oauth({
integrationID: Integration.ID.make(input.integrationID),
key: input.key,
methodID: Integration.MethodID.make(input.methodID),
inputs: input.inputs,
label: input.label,
}),
oauth: (input) =>
response(
integration.connection.oauth({
integrationID: Integration.ID.make(input.integrationID),
methodID: Integration.MethodID.make(input.methodID),
inputs: input.inputs,
label: input.label,
}),
),
},
attempt: {
status: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))),
complete: (input) =>
integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }),
cancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)),
},
),
attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))),
attemptComplete: (input) =>
integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }),
attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)),
reload: integration.reload,
connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)),
@@ -323,12 +317,11 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
registrations,
(registration) => tools.register({ [registration.name]: registration.tool }, registration.options),
{ discard: true },
).pipe(Effect.orDie)
return { dispose: Effect.void }
)
}),
hook: (name, callback) => {
if (name === "execute.before") {
return toolHooks.hook.before((event) => {
execute: {
before: (callback) =>
toolHooks.hook.before((event) => {
const output = {
tool: event.tool,
sessionID: event.sessionID,
@@ -337,37 +330,38 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
toolCallID: event.toolCallID,
input: event.input,
}
return Reflect.apply(callback, undefined, [output]).pipe(
const result = callback(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() => Effect.sync(() => (event.input = output.input))),
)
})
}
return toolHooks.hook.after((event) => {
const output = {
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
assistantMessageID: event.assistantMessageID,
toolCallID: event.toolCallID,
input: event.input,
result: event.result,
output: event.output,
outputPaths: event.outputPaths,
}
return Reflect.apply(callback, undefined, [output]).pipe(
Effect.tap(() =>
Effect.sync(() => {
event.result = output.result
event.output = output.output
event.outputPaths = output.outputPaths
}),
),
)
})
}),
after: (callback) =>
toolHooks.hook.after((event) => {
const output = {
tool: event.tool,
sessionID: event.sessionID,
agent: event.agent,
assistantMessageID: event.assistantMessageID,
toolCallID: event.toolCallID,
input: event.input,
result: event.result,
output: event.output,
outputPaths: event.outputPaths,
}
const result = callback(output)
return Effect.suspend(() => (Effect.isEffect(result) ? result : Effect.void)).pipe(
Effect.tap(() =>
Effect.sync(() => {
event.result = output.result
event.output = output.output
event.outputPaths = output.outputPaths
}),
),
)
}),
},
},
session: {
hook: (name, callback) => hooks.register("session", name, callback),
create: (input) =>
runtime.session.create({
id: input?.id,
@@ -381,5 +375,5 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
command: runtime.session.command,
interrupt: (input) => runtime.session.interrupt(input.sessionID),
},
} satisfies Plugin.Context
} satisfies PluginContext
})
+3 -3
View File
@@ -1,6 +1,6 @@
export * as PluginInternal from "./internal"
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Scope } from "effect"
import { HttpClient } from "effect/unstable/http"
import { AgentV2 } from "../agent"
@@ -31,7 +31,7 @@ import { SessionInstructions } from "../session/instructions"
import { SessionTodo } from "../session/todo"
import { Shell } from "../shell"
import { SkillV2 } from "../skill"
import { PatchTool } from "../tool/patch"
import { ApplyPatchTool } from "../tool/apply-patch"
import { EditTool } from "../tool/edit"
import { GlobTool } from "../tool/glob"
import { GrepTool } from "../tool/grep"
@@ -127,7 +127,7 @@ const pre = [
SkillPlugin.Plugin,
ModelsDevPlugin,
...ProviderPlugins,
PatchTool.Plugin,
ApplyPatchTool.Plugin,
EditTool.Plugin,
GlobTool.Plugin,
GrepTool.Plugin,
+20 -34
View File
@@ -1,6 +1,5 @@
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Money } from "@opencode-ai/schema/money"
import type { ModelInfo } from "@opencode-ai/sdk/v2/types"
import type { ModelV2Info } from "@opencode-ai/sdk/v2/types"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
@@ -12,13 +11,13 @@ function released(date: string) {
return Number.isFinite(time) ? time : 0
}
function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] {
function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] {
const base = {
input: input?.input ?? Money.USDPerMillionTokens.zero,
output: input?.output ?? Money.USDPerMillionTokens.zero,
input: input?.input ?? 0,
output: input?.output ?? 0,
cache: {
read: input?.cache_read ?? Money.USDPerMillionTokens.zero,
write: input?.cache_write ?? Money.USDPerMillionTokens.zero,
read: input?.cache_read ?? 0,
write: input?.cache_write ?? 0,
},
}
return [
@@ -28,8 +27,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] {
input: item.input,
output: item.output,
cache: {
read: item.cache_read ?? Money.USDPerMillionTokens.zero,
write: item.cache_write ?? Money.USDPerMillionTokens.zero,
read: item.cache_read ?? 0,
write: item.cache_write ?? 0,
},
})) ?? []),
...(input?.context_over_200k
@@ -42,8 +41,8 @@ function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] {
input: input.context_over_200k.input,
output: input.context_over_200k.output,
cache: {
read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero,
write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero,
read: input.context_over_200k.cache_read ?? 0,
write: input.context_over_200k.cache_write ?? 0,
},
},
]
@@ -51,13 +50,13 @@ function cost(input: ModelsDev.Model["cost"]): ModelInfo["cost"] {
]
}
function mergeCost(base: ModelInfo["cost"], override: ModelsDev.Model["cost"] | undefined) {
function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) {
if (!override) return base
const next = cost(override)
const [baseDefault, ...baseTiers] = base
const [nextDefault, ...nextTiers] = next
const tierKey = (item: ModelInfo["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}`
const merge = (left: ModelInfo["cost"][number], right: ModelInfo["cost"][number]) => ({
const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}`
const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({
...left,
...right,
tier: right.tier ?? left.tier,
@@ -68,25 +67,12 @@ function mergeCost(base: ModelInfo["cost"], override: ModelsDev.Model["cost"] |
const current = tiers.get(tierKey(item))
tiers.set(tierKey(item), current ? merge(current, item) : item)
}
return [
merge(
baseDefault ?? {
input: Money.USDPerMillionTokens.zero,
output: Money.USDPerMillionTokens.zero,
cache: {
read: Money.USDPerMillionTokens.zero,
write: Money.USDPerMillionTokens.zero,
},
},
nextDefault,
),
...tiers.values(),
]
return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()]
}
const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable<ModelInfo["variants"]> {
function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): NonNullable<ModelV2Info["variants"]> {
const npm = model.provider?.npm ?? provider.npm
const options = model.reasoning_options ?? []
const effort = options.find((option) => option.type === "effort")
@@ -131,7 +117,7 @@ function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.
function budgetVariants(
npm: string | undefined,
option: Extract<NonNullable<ModelsDev.Model["reasoning_options"]>[number], { type: "budget_tokens" }>,
): NonNullable<ModelInfo["variants"]> {
): NonNullable<ModelV2Info["variants"]> {
const max = option.max
const high =
option.max === undefined
@@ -160,7 +146,7 @@ function modeName(model: ModelsDev.Model, mode: string) {
return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}`
}
function mergeVariants(model: ModelInfo, next: NonNullable<ModelInfo["variants"]>) {
function mergeVariants(model: ModelV2Info, next: NonNullable<ModelV2Info["variants"]>) {
const variants = model.variants ?? []
const existing = new Map(variants.map((variant) => [variant.id, variant]))
const nextIDs = new Set(next.map((variant) => variant.id))
@@ -171,13 +157,13 @@ function mergeVariants(model: ModelInfo, next: NonNullable<ModelInfo["variants"]
}
function applyModel(
draft: ModelInfo,
draft: ModelV2Info,
model: ModelsDev.Model,
input: {
readonly name?: string
readonly cost?: ModelInfo["cost"]
readonly cost?: ModelV2Info["cost"]
readonly request?: NonNullable<NonNullable<ModelsDev.Model["experimental"]>["modes"]>[string]["provider"]
readonly variants?: NonNullable<ModelInfo["variants"]>
readonly variants?: NonNullable<ModelV2Info["variants"]>
} = {},
) {
draft.name = input.name ?? model.name
+14 -24
View File
@@ -1,12 +1,11 @@
export * as PluginPromise from "./promise"
import { Plugin } from "@opencode-ai/plugin/v2/effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise"
import { Effect, Scope, Stream } from "effect"
type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> }
type PromisePlugin = import("@opencode-ai/plugin/v2/plugin").Plugin
type PromisePluginContext = import("@opencode-ai/plugin/v2/plugin").Context
/**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
@@ -17,8 +16,8 @@ type PromisePluginContext = import("@opencode-ai/plugin/v2/plugin").Context
* preserves boot-time batching, so Promise-plugin transforms still coalesce
* into one reload per domain.
*/
export function fromPromise(plugin: PromisePlugin) {
return Plugin.define({
export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
effect: (host) =>
Effect.gen(function* () {
@@ -44,7 +43,7 @@ export function fromPromise(plugin: PromisePlugin) {
}),
)
const context2: PromisePluginContext = {
const context2: PluginContext = {
options: host.options,
agent: {
list: (input) => run(host.agent.list(input)),
@@ -52,8 +51,10 @@ export function fromPromise(plugin: PromisePlugin) {
reload: () => run(host.agent.reload()),
},
aisdk: {
hook: (name, callback) =>
register(host.aisdk.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
sdk: (callback) =>
register(host.aisdk.sdk((event) => Effect.promise(() => Promise.resolve(callback(event))))),
language: (callback) =>
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
catalog: {
provider: {
@@ -78,15 +79,11 @@ export function fromPromise(plugin: PromisePlugin) {
integration: {
list: (input) => run(host.integration.list(input)),
get: (input) => run(host.integration.get(input)),
connect: {
key: (input) => run(host.integration.connect.key(input)),
oauth: (input) => run(host.integration.connect.oauth(input)),
},
attempt: {
status: (input) => run(host.integration.attempt.status(input)),
complete: (input) => run(host.integration.attempt.complete(input)),
cancel: (input) => run(host.integration.attempt.cancel(input)),
},
connectKey: (input) => run(host.integration.connectKey(input)),
connectOauth: (input) => run(host.integration.connectOauth(input)),
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
attemptCancel: (input) => run(host.integration.attemptCancel(input)),
transform: transform(host.integration),
reload: () => run(host.integration.reload()),
connection: {
@@ -107,19 +104,12 @@ export function fromPromise(plugin: PromisePlugin) {
transform: transform(host.skill),
reload: () => run(host.skill.reload()),
},
tool: {
transform: transform(host.tool),
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
session: {
create: (input) => run(host.session.create(input)),
get: (input) => run(host.session.get(input)),
prompt: (input) => run(host.session.prompt(input)),
command: (input) => run(host.session.command(input)),
interrupt: (input) => run(host.session.interrupt(input)),
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
}
+1 -2
View File
@@ -4,8 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const AlibabaPlugin = define({
id: "opencode.provider.alibaba",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/alibaba") return
const mod = yield* Effect.promise(() => import("@ai-sdk/alibaba"))
@@ -75,8 +75,7 @@ export const AmazonBedrockPlugin = define({
})
}
})
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
const options = { ...evt.options }
@@ -109,8 +108,7 @@ export const AmazonBedrockPlugin = define({
evt.sdk = mod.createAmazonBedrock(options)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.amazonBedrock) return
if (
@@ -17,8 +17,7 @@ export const AnthropicPlugin = define({
})
}
})
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/anthropic"))
+3 -6
View File
@@ -26,8 +26,7 @@ export const AzurePlugin = define({
})
}
})
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return
if (evt.model.providerID === ProviderV2.ID.azure) {
@@ -45,8 +44,7 @@ export const AzurePlugin = define({
evt.sdk = mod.createAzure(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.azure) return
evt.language = selectLanguage(
@@ -77,8 +75,7 @@ export const AzureCognitiveServicesPlugin = define({
})
}
})
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
evt.language = selectLanguage(
@@ -14,8 +14,7 @@ export const CerebrasPlugin = define({
})
}
})
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cerebras") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
@@ -6,8 +6,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare-ai-gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "ai-gateway-provider") return
if (evt.options.baseURL) return
@@ -19,8 +19,7 @@ export const CloudflareWorkersAIPlugin = define({
if (accountId) provider.settings = { ...provider.settings, baseURL: workersEndpoint(accountId) }
})
})
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
if (evt.package !== "@ai-sdk/openai-compatible") return
@@ -36,8 +35,7 @@ export const CloudflareWorkersAIPlugin = define({
)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
evt.language = evt.sdk.languageModel(evt.model.modelID ?? evt.model.id)
+1 -2
View File
@@ -4,8 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const CoherePlugin = define({
id: "opencode.provider.cohere",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cohere") return
const mod = yield* Effect.promise(() => import("@ai-sdk/cohere"))
@@ -4,8 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const DeepInfraPlugin = define({
id: "opencode.provider.deepinfra",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/deepinfra") return
const mod = yield* Effect.promise(() => import("@ai-sdk/deepinfra"))
+1 -2
View File
@@ -7,8 +7,7 @@ export const DynamicProviderPlugin = define({
id: "opencode.provider.dynamic",
effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.sdk) return
+1 -2
View File
@@ -4,8 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GatewayPlugin = define({
id: "opencode.provider.gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/gateway") return
const mod = yield* Effect.promise(() => import("@ai-sdk/gateway"))
@@ -23,16 +23,14 @@ export const GithubCopilotPlugin = define({
model.enabled = false
})
})
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/github-copilot") return
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
evt.sdk = mod.createOpenaiCompatible(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
+2 -4
View File
@@ -7,8 +7,7 @@ import { ProviderV2 } from "../../provider"
export const GitLabPlugin = define({
id: "opencode.provider.gitlab",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "gitlab-ai-provider") return
const mod = yield* Effect.promise(() => import("gitlab-ai-provider"))
@@ -32,8 +31,7 @@ export const GitLabPlugin = define({
})
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.gitlab) return
const featureFlags =
@@ -85,8 +85,7 @@ export const GoogleVertexPlugin = define({
})
}
})
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
evt.options.fetch = authFetch(evt.options.fetch)
@@ -105,8 +104,7 @@ export const GoogleVertexPlugin = define({
})
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.googleVertex) return
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
@@ -137,8 +135,7 @@ export const GoogleVertexAnthropicPlugin = define({
})
}
})
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google-vertex/anthropic"))
@@ -164,8 +161,7 @@ export const GoogleVertexAnthropicPlugin = define({
})
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("google-vertex-anthropic")) return
evt.language = evt.sdk.languageModel(String(evt.model.modelID ?? evt.model.id).trim())
+1 -2
View File
@@ -4,8 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GooglePlugin = define({
id: "opencode.provider.google",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google") return
const mod = yield* Effect.promise(() => import("@ai-sdk/google"))
+1 -2
View File
@@ -4,8 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const GroqPlugin = define({
id: "opencode.provider.groq",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/groq") return
const mod = yield* Effect.promise(() => import("@ai-sdk/groq"))
+1 -2
View File
@@ -4,8 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const MistralPlugin = define({
id: "opencode.provider.mistral",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/mistral") return
const mod = yield* Effect.promise(() => import("@ai-sdk/mistral"))
@@ -4,8 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const OpenAICompatiblePlugin = define({
id: "opencode.provider.openai-compatible",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.sdk) return
if (!evt.package.includes("@ai-sdk/openai-compatible")) return
+2 -4
View File
@@ -210,16 +210,14 @@ export const OpenAIPlugin = define({
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh().pipe(Effect.forkScoped)
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai"))
evt.sdk = mod.createOpenAI(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openai) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
+7 -11
View File
@@ -10,7 +10,6 @@ import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { ConfigProviderV1 } from "../../v1/config/provider"
import { Money } from "@opencode-ai/schema/money"
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options"
import { ConfigV1 } from "../../v1/config/config"
@@ -221,23 +220,20 @@ function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined)
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
const base = {
input: Money.USDPerMillionTokens.make(input.input),
output: Money.USDPerMillionTokens.make(input.output),
cache: {
read: Money.USDPerMillionTokens.make(input.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.cache_write ?? 0),
},
input: input.input,
output: input.output,
cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 },
}
if (!input.context_over_200k) return [base]
return [
base,
{
tier: { type: "context" as const, size: 200_000 },
input: Money.USDPerMillionTokens.make(input.context_over_200k.input),
output: Money.USDPerMillionTokens.make(input.context_over_200k.output),
input: input.context_over_200k.input,
output: input.context_over_200k.output,
cache: {
read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0),
read: input.context_over_200k.cache_read ?? 0,
write: input.context_over_200k.cache_write ?? 0,
},
},
]
@@ -23,8 +23,7 @@ export const OpenRouterPlugin = define({
}
}
})
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
@@ -4,8 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const PerplexityPlugin = define({
id: "opencode.provider.perplexity",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/perplexity") return
const mod = yield* Effect.promise(() => import("@ai-sdk/perplexity"))
@@ -8,8 +8,7 @@ export const SapAICorePlugin = define({
id: "opencode.provider.sap-ai-core",
effect: Effect.fn(function* (ctx) {
const npm = yield* Npm.Service
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
const serviceKey =
@@ -38,8 +37,7 @@ export const SapAICorePlugin = define({
)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("sap-ai-core")) return
evt.language = evt.sdk(evt.model.modelID ?? evt.model.id)
@@ -67,8 +67,7 @@ export function cortexFetch(upstream: FetchLike = fetch) {
export const SnowflakeCortexPlugin = define({
id: "opencode.provider.snowflake-cortex",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("snowflake-cortex")) return
const token =
@@ -4,8 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const TogetherAIPlugin = define({
id: "opencode.provider.togetherai",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/togetherai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/togetherai"))
+1 -2
View File
@@ -4,8 +4,7 @@ import { define } from "@opencode-ai/plugin/v2/effect/plugin"
export const VenicePlugin = define({
id: "opencode.provider.venice",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "venice-ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("venice-ai-sdk-provider"))
+1 -2
View File
@@ -14,8 +14,7 @@ export const VercelPlugin = define({
})
}
})
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/vercel") return
const mod = yield* Effect.promise(() => import("@ai-sdk/vercel"))
+2 -4
View File
@@ -5,16 +5,14 @@ import { ProviderV2 } from "../../provider"
export const XAIPlugin = define({
id: "opencode.provider.xai",
effect: Effect.fn(function* (ctx) {
yield* ctx.aisdk.hook(
"sdk",
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/xai") return
const mod = yield* Effect.promise(() => import("@ai-sdk/xai"))
evt.sdk = mod.createXai(evt.options)
}),
)
yield* ctx.aisdk.hook(
"language",
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("xai")) return
evt.language = evt.sdk.responses(evt.model.modelID ?? evt.model.id)
+1 -1
View File
@@ -1,6 +1,6 @@
export * as SdkPlugins from "./sdk"
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event"
+10 -19
View File
@@ -33,8 +33,7 @@ export const Plugin = define({
SkillV2.EmbeddedSource.make({
type: "embedded",
skill: SkillV2.Info.make({
id: SkillV2.ID.make("opencode"),
name: SkillV2.Name.make("OpenCode"),
name: "opencode",
description: OpencodeDescription,
location: AbsolutePath.make("/builtin/opencode.md"),
content: OpencodeContent,
@@ -45,8 +44,7 @@ export const Plugin = define({
SkillV2.EmbeddedSource.make({
type: "embedded",
skill: SkillV2.Info.make({
id: SkillV2.ID.make("report"),
name: SkillV2.Name.make("Report"),
name: "report",
description: REPORT_DESCRIPTION,
slash: true,
location: AbsolutePath.make("/builtin/report.md"),
@@ -105,22 +103,15 @@ const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* (
})
function terminal() {
return (
[
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined,
]
.filter((item): item is string => item !== undefined)
.join(", ") || "Unavailable: terminal environment variables are not set"
)
return [
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined,
]
.filter((item): item is string => item !== undefined)
.join(", ") || "Unavailable: terminal environment variables are not set"
}
function shell() {
return (
process.env.SHELL ??
process.env.ComSpec ??
process.env.COMSPEC ??
"Unavailable: shell environment variable is not set"
)
return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set"
}
+1 -1
View File
@@ -1,6 +1,6 @@
export * as PluginSupervisor from "./supervisor"
import type { Plugin } from "@opencode-ai/plugin/v2/effect/plugin"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Event } from "@opencode-ai/schema/config"
import { Context, Effect, Fiber, Layer, Option, Schema, Semaphore, Stream } from "effect"
import path from "path"
+19 -22
View File
@@ -1,7 +1,7 @@
export * as SessionV2 from "./session"
export * from "./session/schema"
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project"
@@ -19,7 +19,6 @@ import { SessionSchema } from "./session/schema"
import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
import { AgentV2 } from "./agent"
import { SessionV1 } from "./v1/session"
import { Money } from "@opencode-ai/schema/money"
import { InstallationVersion } from "./installation/version"
import { Slug } from "./util/slug"
import { ProjectTable } from "./project/sql"
@@ -35,7 +34,7 @@ import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert"
import { Session } from "@opencode-ai/schema/session"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
import { Mime } from "./mime"
import type { EventLog } from "@opencode-ai/schema/event-log"
@@ -47,8 +46,8 @@ import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex"
import { fileURLToPath } from "url"
export const RevertState = Session.Revert
export type RevertState = Session.Revert
export const RevertState = Revert.State
export type RevertState = Revert.State
// get project -> project.locations
//
@@ -137,7 +136,7 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.Bus
sessionID: SessionSchema.ID,
}) {}
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: SkillV2.ID,
skill: Schema.String,
}) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
@@ -171,14 +170,14 @@ export interface Interface {
id: SessionMessage.ID
direction: "previous" | "next"
}
}) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
readonly message: (input: {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
}) => Effect.Effect<SessionMessage.Info | undefined>
}) => Effect.Effect<SessionMessage.Message | undefined>
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
/**
* Durable, ordered, gap-free session log read. Replays public durable
* session events after the exclusive `after` cursor, emits a `Synced`
@@ -192,10 +191,7 @@ export interface Interface {
after?: number
follow?: boolean
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
readonly switchAgent: (input: {
sessionID: SessionSchema.ID
agent: AgentV2.ID
}) => Effect.Effect<void, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: {
sessionID: SessionSchema.ID
model: ModelV2.Ref
@@ -213,7 +209,7 @@ export interface Interface {
sessionID: SessionSchema.ID
command: string
arguments?: string
agent?: AgentV2.ID
agent?: string
model?: ModelV2.Ref
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
@@ -231,7 +227,7 @@ export interface Interface {
readonly skill: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
skill: SkillV2.ID
skill: string
resume?: boolean
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: (
@@ -254,7 +250,7 @@ export interface Interface {
sessionID: SessionSchema.ID
messageID: SessionMessage.ID
files?: boolean
}) => Effect.Effect<Session.Revert, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
}) => Effect.Effect<Revert.State, NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error>
readonly clear: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError | Snapshot.Error>
readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | BusyError>
}
@@ -277,7 +273,7 @@ const layer = Layer.effect(
const scope = yield* Scope.Scope
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
@@ -326,7 +322,7 @@ const layer = Layer.effect(
variant: input.model.variant,
}
: undefined,
cost: Money.USD.zero,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
})
@@ -533,7 +529,7 @@ const layer = Layer.effect(
})
const model = command.model ?? commandAgent?.model ?? input.model
if (agent !== undefined && session.agent !== AgentV2.ID.make(agent))
yield* result.switchAgent({ sessionID: input.sessionID, agent: AgentV2.ID.make(agent) })
yield* result.switchAgent({ sessionID: input.sessionID, agent })
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
return yield* result.prompt({
@@ -595,13 +591,12 @@ const layer = Layer.effect(
skill: Effect.fn("V2Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location)))
const skill = (yield* skills.list()).find((item) => item.id === input.skill)
const skill = (yield* skills.list()).find((item) => item.name === input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* events.publish(
SessionEvent.Skill.Activated,
{
sessionID: input.sessionID,
id: skill.id,
name: skill.name,
text: skill.content,
},
@@ -689,7 +684,9 @@ const layer = Layer.effect(
metadata: input.metadata,
})
if (input.resume === false) return
yield* execution.wake(input.sessionID, { force: true })
yield* execution
.resume(input.sessionID)
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
+7 -32
View File
@@ -64,21 +64,19 @@ type Dependencies = {
export type AutoInput = {
readonly sessionID: SessionSchema.ID
readonly messages: readonly SessionMessage.Info[]
readonly messages: readonly SessionMessage.Message[]
readonly request: LLMRequest
}
type CompactInput = {
readonly sessionID: SessionSchema.ID
readonly messages: readonly SessionMessage.Info[]
readonly messages: readonly SessionMessage.Message[]
readonly model: Model
readonly inputID?: SessionMessage.ID
}
export type ManualInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly inputID: SessionMessage.ID
readonly messages: readonly SessionMessage.Message[]
}
export interface Interface {
@@ -101,7 +99,7 @@ export const serializeToolContent = (content: SessionMessage.ToolStateCompleted[
)
.join("\n")
const serialize = (message: SessionMessage.Info) => {
const serialize = (message: SessionMessage.Message) => {
if (message.type === "user") {
const files =
message.files?.map(
@@ -130,7 +128,7 @@ const serialize = (message: SessionMessage.Info) => {
if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output?.output ?? "")}`
if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}`
return ""
}
@@ -149,7 +147,7 @@ const settings = (documents: readonly Config.Entry[]) => {
}
const select = (
messages: readonly SessionMessage.Info[],
messages: readonly SessionMessage.Message[],
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = messages
@@ -200,7 +198,6 @@ const make = (dependencies: Dependencies) => {
readonly context: readonly string[]
readonly recent: string
readonly output?: number
readonly inputID?: SessionMessage.ID
}) {
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
@@ -211,8 +208,6 @@ const make = (dependencies: Dependencies) => {
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
sessionID: input.sessionID,
reason: input.reason,
recent: input.recent,
inputID: input.inputID,
})
const chunks: string[] = []
@@ -240,27 +235,9 @@ const make = (dependencies: Dependencies) => {
}),
Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
Effect.onInterrupt(() =>
input.reason === "auto"
? dependencies.events.publish(SessionEvent.Compaction.Failed, {
sessionID: input.sessionID,
reason: input.reason,
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
inputID: input.inputID,
})
: Effect.void,
),
)
const summary = chunks.join("")
if (!summarized || failed || !summary.trim()) {
yield* dependencies.events.publish(SessionEvent.Compaction.Failed, {
sessionID: input.sessionID,
reason: input.reason,
error: { type: "compaction.failed", message: "Compaction produced no summary" },
inputID: input.inputID,
})
return false
}
if (!summarized || failed || !summary.trim()) return false
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
sessionID: input.sessionID,
reason: input.reason,
@@ -307,7 +284,6 @@ const make = (dependencies: Dependencies) => {
),
recent: forcedShortContext ? "" : selected.recent,
output: input.output,
inputID: input.inputID,
})
})
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
@@ -351,7 +327,6 @@ export const layer = Layer.effect(
sessionID: input.session.id,
messages: input.messages,
model: resolved.model,
inputID: input.inputID,
})
}),
})
+2 -3
View File
@@ -4,7 +4,6 @@ import { Context, Effect, Layer } from "effect"
import { LayerNode } from "../effect/layer-node"
import { Node } from "../effect/app-node"
import { SessionRunner } from "./runner/index"
import type { SessionRunCoordinator } from "./run-coordinator"
import { SessionSchema } from "./schema"
export interface Interface {
@@ -12,8 +11,8 @@ export interface Interface {
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
/** Starts execution while idle or joins the active execution. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. Force runs even without pending input. */
readonly wake: (sessionID: SessionSchema.ID, options?: SessionRunCoordinator.WakeOptions) => Effect.Effect<void>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
+1 -1
View File
@@ -8,7 +8,7 @@ import { InstructionCheckpointTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
+4 -7
View File
@@ -1,4 +1,4 @@
import { DateTime, Schema } from "effect"
import { DateTime } from "effect"
import { AgentV2 } from "../agent"
import { Location } from "../location"
import { ModelV2 } from "../model"
@@ -9,10 +9,7 @@ import { WorkspaceV2 } from "../workspace"
import { SessionSchema } from "./schema"
import { SessionTable } from "./sql"
import { SessionMessage } from "./message"
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
import { Money } from "@opencode-ai/schema/money"
const decodeRevert = Schema.decodeUnknownSync(PersistedRevert)
import { Snapshot } from "../snapshot"
export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
return SessionSchema.Info.make({
@@ -34,7 +31,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
}
: undefined,
cost: Money.USD.make(row.cost),
cost: row.cost,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
@@ -49,7 +46,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
}),
subpath: row.path ? RelativePath.make(row.path) : undefined,
revert: row.revert ? decodeRevert(row.revert) : undefined,
revert: row.revert ? { ...row.revert, messageID: SessionMessage.ID.make(row.revert.messageID) } : undefined,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
+3 -3
View File
@@ -2,7 +2,7 @@ export * as SessionInput from "./input"
import { and, asc, eq, isNull } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Admitted, Compaction, Delivery, Info, PromptEntry } from "@opencode-ai/schema/session-input"
import { Admitted, Compaction, Delivery, Entry, PromptEntry } from "@opencode-ai/schema/session-input"
import type { Database } from "../database/database"
import type { EventV2 } from "../event"
import { KeyedMutex } from "../effect/keyed-mutex"
@@ -14,7 +14,7 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
export { Admitted, Compaction, Delivery, Info, PromptEntry }
export { Admitted, Compaction, Delivery, Entry, PromptEntry }
const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt)
@@ -24,7 +24,7 @@ export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict
id: SessionMessage.ID,
}) {}
const fromRow = (row: typeof SessionInputTable.$inferSelect): Info => {
const fromRow = (row: typeof SessionInputTable.$inferSelect): Entry => {
const base = {
admittedSeq: row.admitted_seq,
id: SessionMessage.ID.make(row.id),
+30 -41
View File
@@ -4,7 +4,7 @@ import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
export type MemoryState = {
messages: SessionMessage.Info[]
messages: SessionMessage.Message[]
}
export interface Adapter {
@@ -14,13 +14,13 @@ export interface Adapter {
messageID: SessionMessage.ID,
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getShell: (
shellID: SessionMessage.Shell["shellID"],
shellID: SessionMessage.Shell["shell"]["id"],
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
}
export function memory(state: MemoryState): Adapter {
@@ -29,7 +29,9 @@ export function memory(state: MemoryState): Adapter {
const shellIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
const compactionIndex = () =>
state.messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
state.messages.findLastIndex(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
@@ -62,7 +64,7 @@ export function memory(state: MemoryState): Adapter {
getShell(shellID) {
return Effect.sync(() => {
return state.messages.find((message): message is SessionMessage.Shell => {
return message.type === "shell" && message.shellID === shellID
return message.type === "shell" && message.shell.id === shellID
})
})
},
@@ -184,13 +186,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: SessionMessage.ID.fromEvent(event.id),
type: "system",
text: event.data.text,
metadata: event.metadata,
time: { created: event.created },
}),
),
"session.synthetic": (event) => {
return adapter.appendMessage(
SessionMessage.Synthetic.make({
sessionID: event.data.sessionID,
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
@@ -205,10 +207,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
SessionMessage.Skill.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "skill",
skill: event.data.id,
name: event.data.name,
text: event.data.text,
metadata: event.metadata,
time: { created: event.created },
}),
)
@@ -219,9 +219,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: SessionMessage.ID.fromEvent(event.id),
type: "shell",
metadata: event.metadata,
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
shell: event.data.shell,
time: { created: event.created },
}),
)
@@ -232,8 +230,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
if (currentShell) {
yield* adapter.updateShell(
produce(currentShell, (draft) => {
draft.status = event.data.shell.status
draft.exit = event.data.shell.exit
draft.shell = castDraft(event.data.shell)
draft.output = event.data.output
draft.time.completed = event.created
}),
@@ -273,7 +270,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
type: "assistant",
agent: event.data.agent,
model: event.data.model,
metadata: event.metadata,
time: { created: event.created },
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
@@ -333,7 +329,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
id: event.data.callID,
name: event.data.name,
time: { created: event.created },
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
state: SessionMessage.ToolStatePending.make({ status: "pending", input: "" }),
}),
),
)
@@ -343,7 +339,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "streaming") match.state.input = event.data.text
if (match && match.state.status === "pending") match.state.input = event.data.text
})
},
"session.tool.called": (event) => {
@@ -386,6 +382,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
input: match.state.input,
structured: event.data.structured,
content: [...event.data.content],
outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [],
result: event.data.result,
}),
)
@@ -395,7 +392,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.tool.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "streaming" || match.state.status === "running")) {
if (match && (match.state.status === "pending" || match.state.status === "running")) {
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
@@ -451,30 +448,31 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"session.compaction.admitted": () => Effect.void,
"session.compaction.started": (event) =>
"session.compaction.admitted": (event) =>
adapter.appendMessage(
SessionMessage.CompactionRunning.make({
id: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
SessionMessage.Compaction.make({
id: event.data.inputID,
type: "compaction",
status: "running",
status: "queued",
metadata: event.metadata,
reason: event.data.reason,
reason: "manual",
summary: "",
recent: event.data.recent ?? "",
recent: "",
time: { created: event.created },
}),
),
"session.compaction.delta": (event) =>
"session.compaction.started": (event) =>
Effect.gen(function* () {
if (event.data.reason !== "manual") return
const current = yield* adapter.getCompaction()
if (current?.status !== "running") return
yield* adapter.updateCompaction({ ...current, summary: current.summary + event.data.text })
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "running" })
}),
"session.compaction.delta": () => Effect.void,
"session.compaction.ended": (event) => {
return Effect.gen(function* () {
const current = yield* adapter.getCompaction()
if (current?.status === "running") {
const current = event.data.reason === "manual" ? yield* adapter.getCompaction() : undefined
if (current) {
yield* adapter.updateCompaction({
...current,
status: "completed",
@@ -498,20 +496,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
})
},
"session.compaction.failed": (event) =>
"session.compaction.failed": () =>
Effect.gen(function* () {
const current = yield* adapter.getCompaction()
const failed = SessionMessage.CompactionFailed.make({
id: current?.id ?? event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
type: "compaction",
status: "failed",
metadata: current?.metadata ?? event.metadata,
reason: event.data.reason,
error: event.data.error,
time: current?.time ?? { created: event.created },
})
if (current?.status === "running") return yield* adapter.updateCompaction(failed)
yield* adapter.appendMessage(failed)
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "failed" })
}),
"session.revert.staged": () => Effect.void,
"session.revert.cleared": () => Effect.void,
+40 -40
View File
@@ -24,14 +24,15 @@ import {
} from "./sql"
import type { DeepMutable } from "../schema"
import { Slug } from "../util/slug"
import { Money } from "@opencode-ai/schema/money"
type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
type MessageEvent = Exclude<
SessionEvent.DurableEvent,
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
export class SessionAlreadyProjected extends Error {}
@@ -86,14 +87,7 @@ function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInse
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
revert: info.revert
? {
messageID: SessionMessage.ID.make(info.revert.messageID),
partID: info.revert.partID,
snapshot: info.revert.snapshot,
diff: info.revert.diff,
}
: null,
revert: info.revert ? { ...info.revert, messageID: SessionMessage.ID.make(info.revert.messageID) } : null,
permission: info.permission ? [...info.permission] : undefined,
time_created: info.time.created,
time_updated: info.time.updated,
@@ -157,7 +151,7 @@ const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function*
if (!row) return
yield* events.publish(SessionEvent.UsageUpdated, {
sessionID,
cost: Money.USD.make(row.cost),
cost: row.cost,
tokens: {
input: row.input,
output: row.output,
@@ -263,7 +257,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
eq(SessionMessageTable.session_id, event.data.parentID),
gt(SessionMessageTable.seq, cursor),
lt(SessionMessageTable.seq, copiedSeq + 1),
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`,
),
)
.orderBy(asc(SessionMessageTable.seq))
@@ -286,7 +280,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
seq: row.seq,
time_created: row.time_created,
time_updated: row.time_updated,
data: row.data,
data: row.type === "synthetic" ? { ...row.data, sessionID: event.data.sessionID } : row.data,
}
}),
)
@@ -342,7 +336,7 @@ function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
const updateMessage = (message: SessionMessage.Info) => {
const updateMessage = (message: SessionMessage.Message) => {
if (event.durable === undefined)
return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const encoded = encodeMessage(message)
@@ -359,7 +353,7 @@ function run(db: DatabaseService, event: MessageEvent) {
.run()
.pipe(Effect.orDie)
}
const appendMessage = (message: SessionMessage.Info) => insertMessage(db, event, message)
const appendMessage = (message: SessionMessage.Message) => insertMessage(db, event, message)
const adapter: SessionMessageUpdater.Adapter = {
getModel() {
return db
@@ -418,7 +412,7 @@ function run(db: DatabaseService, event: MessageEvent) {
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "shell"),
sql`json_extract(${SessionMessageTable.data}, '$.shellID') = ${shellID}`,
sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`,
),
)
.orderBy(desc(SessionMessageTable.seq))
@@ -439,7 +433,7 @@ function run(db: DatabaseService, event: MessageEvent) {
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
sql`json_extract(${SessionMessageTable.data}, '$.status') in ('queued', 'running')`,
),
)
.orderBy(desc(SessionMessageTable.seq))
@@ -460,7 +454,7 @@ function run(db: DatabaseService, event: MessageEvent) {
})
}
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Message) {
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
@@ -667,12 +661,14 @@ const layer = Layer.effectDiscard(
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInput.projectCompactionAdmitted(db, {
const admitted = yield* SessionInput.projectCompactionAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
timeCreated: event.created,
})
if (admitted.id !== event.data.inputID) return
yield* run(db, event)
}),
)
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
@@ -680,7 +676,15 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Skill.Activated, (event) => run(db, event))
yield* events.project(SessionEvent.Skill.Activated, (event) =>
insertMessage(db, event, {
id: SessionMessage.ID.fromEvent(event.id),
type: "skill",
name: event.data.name,
text: event.data.text,
time: { created: event.created },
}),
)
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
@@ -726,26 +730,22 @@ const layer = Layer.effectDiscard(
yield* run(db, event)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
if (event.data.reason === "manual")
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
}),
)
yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
Effect.gen(function* () {
const revert = event.data.revert
yield* db
.update(SessionTable)
.set({
revert: { ...revert, files: revert.files ? [...revert.files] : undefined },
time_updated: DateTime.toEpochMillis(event.created),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
}),
db
.update(SessionTable)
.set({
revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined },
time_updated: DateTime.toEpochMillis(event.created),
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.asVoid),
)
yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
db
+8 -4
View File
@@ -1,7 +1,7 @@
export * as SessionRevert from "./revert"
import { and, asc, eq, gt } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { DateTime, Effect, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { RelativePath } from "../schema"
@@ -46,7 +46,7 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
@@ -70,7 +70,7 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
const next = yield* plan({ sessionID: input.session.id, messageID: input.messageID })
const restore = new Map<RelativePath, Snapshot.ID>()
if (original) {
for (const file of input.session.revert?.files ?? []) restore.set(RelativePath.make(file.file), original)
for (const file of input.session.revert?.files ?? []) restore.set(file.path, original)
}
if (input.files !== false) for (const [file, tree] of next) restore.set(file, tree)
if (restore.size) yield* snapshot.restore({ files: restore })
@@ -81,6 +81,10 @@ export const stage = Effect.fn("SessionRevert.stage")(function* (input: {
const revert = {
messageID: input.messageID,
snapshot: original,
diff: files
.map((file) => file.patch)
.join("")
.trim(),
files,
} satisfies SessionSchema.Info["revert"]
yield* events.publish(SessionEvent.RevertEvent.Staged, {
@@ -96,7 +100,7 @@ export const clear = Effect.fn("SessionRevert.clear")(function* (session: Sessio
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
if (original)
yield* snapshot.restore({
files: new Map((session.revert.files ?? []).map((file) => [RelativePath.make(file.file), original])),
files: new Map((session.revert.files ?? []).map((file) => [file.path, original])),
})
const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Cleared, {
+5 -14
View File
@@ -9,15 +9,13 @@ export interface Coordinator<Key, E, Reason = never> {
/** Starts an execution while idle, or joins the active execution and returns its exit. */
readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key, options?: WakeOptions) => Effect.Effect<void>
readonly wake: (key: Key) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
export type WakeOptions = { readonly force?: boolean }
/**
* One execution is a busy period for one key: one fiber that drains from the first wake
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
@@ -29,7 +27,6 @@ type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void>
pendingWake: boolean
pendingForce: boolean
stopping: boolean
settling: boolean
interruptionReason?: Reason
@@ -66,10 +63,8 @@ export const make = <Key, E, Reason = never>(options: {
Effect.suspend(() => {
if (execution.stopping || !execution.pendingWake) return Effect.void
execution.pendingWake = false
const force = execution.pendingForce
execution.pendingForce = false
// Trampoline so drains that complete synchronously cannot grow the stack.
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, force)))
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
}),
),
)
@@ -78,7 +73,6 @@ export const make = <Key, E, Reason = never>(options: {
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
pendingWake: false,
pendingForce: false,
stopping: false,
settling: false,
}
@@ -106,7 +100,7 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, execution.pendingForce)
if (execution.pendingWake) start(key, false)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
}
@@ -122,16 +116,14 @@ export const make = <Key, E, Reason = never>(options: {
return restore(Deferred.await(start(key, true).done))
})
const wake = (key: Key, options?: { readonly force?: boolean }) =>
const wake = (key: Key) =>
Effect.sync(() => {
const force = options?.force === true
const execution = executions.get(key)
if (execution !== undefined) {
execution.pendingWake = true
execution.pendingForce ||= force
return
}
start(key, force)
start(key, false)
})
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
@@ -140,7 +132,6 @@ export const make = <Key, E, Reason = never>(options: {
if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
execution.stopping = true
execution.pendingWake = false
execution.pendingForce = false
execution.interruptionReason = reason
return Fiber.interrupt(execution.owner)
})
+13 -74
View File
@@ -11,7 +11,6 @@ import {
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
@@ -51,8 +50,6 @@ import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError, UserInterruptedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
import { PluginHooks } from "../../plugin/hooks"
type StepTokens = {
readonly input: number
@@ -68,13 +65,13 @@ export function calculateCost(costs: ModelV2.Info["cost"], tokens: StepTokens) {
.filter((cost) => cost.tier?.type === "context" && context > cost.tier.size)
.toSorted((a, b) => (b.tier?.size ?? 0) - (a.tier?.size ?? 0))[0]
const cost = tier ?? costs.find((cost) => cost.tier === undefined)
if (!cost) return Money.USD.zero
return Money.USD.make(
if (!cost) return 0
return (
(tokens.input * cost.input +
(tokens.output + tokens.reasoning) * cost.output +
tokens.cache.read * cost.cache.read +
tokens.cache.write * cost.cache.write) /
1_000_000,
1_000_000
)
}
@@ -135,7 +132,6 @@ const layer = Layer.effect(
const llm = yield* LLMClient.Service
const agents = yield* AgentV2.Service
const tools = yield* ToolRegistry.Service
const hooks = yield* PluginHooks.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const location = yield* Location.Service
@@ -166,7 +162,7 @@ const layer = Layer.effect(
for (const message of yield* store.context(sessionID)) {
if (message.type !== "assistant") continue
for (const tool of message.content) {
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
if (tool.type !== "tool" || (tool.state.status !== "pending" && tool.state.status !== "running")) continue
yield* events.publish(SessionEvent.Tool.Failed, {
sessionID,
assistantMessageID: message.id,
@@ -233,7 +229,6 @@ const layer = Layer.effect(
}
const resolved = yield* models.resolve(session)
const model = resolved.model
const providerMetadataKey = model.route.providerMetadataKey ?? model.provider
const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq)
const context = entries.map((entry) => entry.message)
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
@@ -251,40 +246,16 @@ const layer = Layer.effect(
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [
...toLLMMessages(context, resolved.ref, providerMetadataKey),
...toLLMMessages(context, resolved.ref),
...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : []),
],
tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined,
})
const availableTools = new Map(request.tools.map((tool) => [tool.name, tool]))
const requestEvent: SessionHooks["request"] = {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
system: [...request.system],
messages: [...request.messages],
tools: Object.fromEntries(
request.tools.map((tool) => [tool.name, { description: tool.description, input: { ...tool.inputSchema } }]),
),
}
// Plugins may reshape the draft, but cannot advertise tools excluded earlier
// by permissions or registration state.
yield* hooks.trigger("session", "request", requestEvent)
const hookedRequest = LLM.updateRequest(request, {
system: requestEvent.system,
messages: requestEvent.messages,
tools: Object.entries(requestEvent.tools).flatMap(([name, tool]) => {
const registered = availableTools.get(name)
if (!registered) return []
return [{ ...registered, description: tool.description, inputSchema: tool.input }]
}),
})
const advertisedTools = new Set(hookedRequest.tools.map((tool) => tool.name))
// Automatic compaction completed; rebuild the request from compacted history.
if (
!(yield* SessionInput.pendingCompaction(db, session.id)) &&
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request: hookedRequest }))
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
)
return { _tag: "RestartAfterCompaction", step: currentStep } as const
const startSnapshot = yield* snapshots.capture()
@@ -294,7 +265,7 @@ const layer = Layer.effect(
// The selected catalog identity, not model.id: route-level ids are provider API
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
model: resolved.ref,
providerMetadataKey,
provider: model.provider,
snapshot: startSnapshot,
assistantMessageID,
})
@@ -302,9 +273,10 @@ const layer = Layer.effect(
// Durable publishes are serialized so tool fibers and step settlement never interleave
// mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
serialized(publisher.publish(event, outputPaths, error))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(hookedRequest).pipe(
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
@@ -325,21 +297,6 @@ const layer = Layer.effect(
)
return
}
// A request hook hid this registered tool from the current request. Fail only
// this call durably and continue so the model can react, instead of executing
// a tool that was not advertised. Unregistered tools flow through settle, which
// durably fails them as unknown.
if (!advertisedTools.has(event.name) && availableTools.has(event.name)) {
needsContinuation = true
yield* publish(
LLMEvent.toolError({
id: event.id,
name: event.name,
message: `Tool is not available for this request: ${event.name}`,
}),
)
return
}
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
ownedToolFibers.push(
@@ -360,6 +317,7 @@ const layer = Layer.effect(
result: settlement.result,
output: settlement.output,
}),
settlement.outputPaths ?? [],
settlement.error,
).pipe(
Effect.andThen(
@@ -599,30 +557,12 @@ const layer = Layer.effect(
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: pending.id,
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted) && compacted.value) return true
if (Exit.isFailure(compacted)) {
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
if (unsettled)
yield* events.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: unsettled.id,
})
return yield* Effect.failCause(compacted.cause)
}
const unsettled = yield* SessionInput.pendingCompaction(db, sessionID)
if (unsettled)
yield* events.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: { type: "compaction.failed", message: "Compaction could not start" },
inputID: unsettled.id,
})
yield* events.publish(SessionEvent.Compaction.Failed, { sessionID })
if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause)
return true
}),
)
@@ -685,7 +625,6 @@ export const node = makeLocationNode({
llmClient,
AgentV2.node,
ToolRegistry.node,
PluginHooks.node,
SessionRunnerModel.node,
SessionStore.node,
Location.node,
@@ -6,16 +6,13 @@ import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Money } from "@opencode-ai/schema/money"
import { AgentV2 } from "../../agent"
import { Snapshot } from "../../snapshot"
type Input = {
readonly sessionID: SessionSchema.ID
readonly agent: AgentV2.ID
readonly agent: string
readonly model: ModelV2.Ref
readonly providerMetadataKey: string
readonly snapshot?: Snapshot.ID
readonly provider: string
readonly snapshot?: string
readonly assistantMessageID?: SessionMessage.ID
}
@@ -87,9 +84,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID ??= SessionMessage.ID.create()
stepStarted = true
yield* events.publish(SessionEvent.Step.Started, {
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
...input,
assistantMessageID,
snapshot: input.snapshot,
})
@@ -99,42 +94,34 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID === undefined
? Effect.die(new Error("Tool event before assistant step start"))
: Effect.succeed(assistantMessageID)
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.providerMetadataKey]
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider]
const fragments = (
name: string,
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
single = false,
) => {
const chunks = new Map<
string,
{ readonly ordinal: number; readonly values: string[]; state?: Record<string, unknown> }
>()
const chunks = new Map<string, { readonly ordinal: number; readonly values: string[] }>()
let nextOrdinal = 0
const start = (id: string, state?: Record<string, unknown>) =>
const start = (id: string) =>
Effect.suspend(() => {
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
const ordinal = nextOrdinal++
chunks.set(id, { ordinal, values: [], state })
chunks.set(id, { ordinal, values: [] })
return Effect.succeed(ordinal)
})
const append = (id: string, value: string, state?: Record<string, unknown>) =>
const append = (id: string, value: string) =>
Effect.suspend(() => {
const current = chunks.get(id)
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
current.values.push(value)
if (state !== undefined) current.state = { ...current.state, ...state }
return Effect.succeed(current.ordinal)
})
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
yield* ended(
id,
current.values.join(""),
current.ordinal,
state === undefined ? current.state : { ...current.state, ...state },
)
yield* ended(id, current.values.join(""), current.ordinal, state)
chunks.delete(id)
})
const flush = Effect.fnUntraced(function* () {
@@ -230,7 +217,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
})
const publishStepFailure = Effect.fnUntraced(function* (usage?: {
readonly cost: Money.USD
readonly cost: number
readonly tokens: ReturnType<typeof tokens>
}) {
if (stepFailed || stepFailure === undefined) return
@@ -269,7 +256,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
}
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent, error?: SessionError.Error) {
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
event: LLMEvent,
outputPaths: ReadonlyArray<string> = [],
error?: SessionError.Error,
) {
switch (event.type) {
case "step-start":
yield* startAssistant()
@@ -297,7 +288,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return
case "reasoning-start":
retryEvidence = true
const startedReasoningOrdinal = yield* reasoning.start(event.id, providerState(event.providerMetadata))
const startedReasoningOrdinal = yield* reasoning.start(event.id)
yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(),
@@ -306,11 +297,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
})
return
case "reasoning-delta":
const deltaReasoningOrdinal = yield* reasoning.append(
event.id,
event.text,
providerState(event.providerMetadata),
)
const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text)
yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
@@ -353,13 +340,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
tool.called = true
tool.providerExecuted = event.providerExecuted === true
const state = providerState(event.providerMetadata)
yield* events.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
callID: event.id,
input: record(event.input),
executed: tool.providerExecuted,
state: providerState(event.providerMetadata),
state,
})
return
}
@@ -394,6 +382,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID: tool.assistantMessageID,
callID: event.id,
...result,
outputPaths,
...(executed ? { result: event.result } : {}),
executed,
resultState,
@@ -69,7 +69,7 @@ const providerMetadata = (
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
const toolInput = (tool: SessionMessage.AssistantTool) =>
tool.state.status === "streaming"
tool.state.status === "pending"
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
: tool.state.input
@@ -113,7 +113,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
}
}
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, providerMetadataKey: string) => {
const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
const sameModel =
String(message.model.providerID) === String(model.providerID) && String(message.model.id) === String(model.id)
const reuseProviderMetadata = sameModel && message.error === undefined
@@ -125,7 +125,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
{
type: "reasoning",
text: item.text,
providerMetadata: providerMetadata(providerMetadataKey, item.state),
providerMetadata: providerMetadata(model.providerID, item.state),
},
]
: item.text.length > 0
@@ -133,13 +133,13 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
: []
const call = toolCall(
item,
reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined,
reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
const result = toolResult(
item,
reuseProviderMetadata
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
: undefined,
)
return result ? [call, result] : [call]
@@ -155,7 +155,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
toolResult(
item,
reuseProviderMetadata
? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState)
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
: undefined,
),
)
@@ -168,7 +168,7 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref, provid
]
}
function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, providerMetadataKey: string): Message[] {
function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Message[] {
switch (message.type) {
case "agent-switched":
case "model-switched":
@@ -202,12 +202,12 @@ function toLLMMessage(message: SessionMessage.Info, model: ModelV2.Ref, provider
Message.make({
id: message.id,
role: "user",
content: `Shell command: ${message.command}\n\n${message.output?.output ?? ""}`,
content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`,
metadata: message.metadata,
}),
]
case "assistant":
return assistant(message, model, providerMetadataKey)
return assistant(message, model)
case "compaction":
if (message.status !== "completed") return []
return [
@@ -232,8 +232,5 @@ ${message.recent}
}
/** Translate projected V2 Session history into canonical @opencode-ai/llm context. */
export const toLLMMessages = (
messages: readonly SessionMessage.Info[],
model: ModelV2.Ref,
providerMetadataKey: string = model.providerID,
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))
export const toLLMMessages = (messages: readonly SessionMessage.Message[], model: ModelV2.Ref) =>
messages.flatMap((message) => toLLMMessage(message, model))
+6 -7
View File
@@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
import type { Prompt } from "@opencode-ai/schema/prompt"
import type { SessionInput } from "./input"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { Snapshot } from "../snapshot"
import { PermissionV1 } from "../v1/permission"
import { ProjectV2 } from "../project"
import type { SessionSchema } from "./schema"
@@ -13,11 +13,10 @@ import type { MessageID, PartID, SessionV1 } from "../v1/session"
import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { Instructions } from "../instructions/index"
import type { Session } from "@opencode-ai/schema/session"
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
import type { Revert } from "@opencode-ai/schema/revert"
import type { Schema } from "effect"
type SessionMessageData = Omit<(typeof SessionMessage.Info)["Encoded"], "type" | "id">
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
type V1PartData = Omit<SessionV1.Part, "id" | "sessionID" | "messageID">
@@ -42,7 +41,7 @@ export const SessionTable = sqliteTable(
summary_additions: integer(),
summary_deletions: integer(),
summary_files: integer(),
summary_diffs: text({ mode: "json" }).$type<FileDiff.LegacyInfo[]>(),
summary_diffs: text({ mode: "json" }).$type<Snapshot.LegacyFileDiff[]>(),
metadata: text({ mode: "json" }).$type<Record<string, unknown>>(),
cost: real().notNull().default(0),
tokens_input: integer().notNull().default(0),
@@ -50,7 +49,7 @@ export const SessionTable = sqliteTable(
tokens_reasoning: integer().notNull().default(0),
tokens_cache_read: integer().notNull().default(0),
tokens_cache_write: integer().notNull().default(0),
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
revert: text({ mode: "json" }).$type<Revert.State>(),
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
agent: text(),
model: text({ mode: "json" }).$type<{
@@ -149,7 +148,7 @@ export const SessionInputTable = sqliteTable(
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().$type<SessionInput.Info["type"]>().notNull(),
type: text().$type<SessionInput.Entry["type"]>().notNull(),
prompt: text({ mode: "json" }).$type<Prompt>(),
delivery: text().$type<SessionInput.Delivery>(),
admitted_seq: integer().notNull(),
+3 -3
View File
@@ -13,10 +13,10 @@ import { fromRow } from "./info"
export interface Interface {
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Message[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Message } | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionStore") {}
@@ -25,7 +25,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
return Service.of({
get: Effect.fn("SessionStore.get")(function* (sessionID) {
+15 -21
View File
@@ -28,15 +28,11 @@ export type Source = typeof Source.Type
export const Info = Skill.Info
export type Info = Skill.Info
export const ID = Skill.ID
export type ID = Skill.ID
export const Name = Skill.Name
export type Name = Skill.Name
export const Event = Skill.Event
export const available = (skills: ReadonlyArray<Info>, agent: AgentV2.Info) =>
skills.filter((skill) => PermissionV2.evaluate("skill", skill.id, agent.permissions).effect !== "deny")
skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny")
const Frontmatter = Schema.Struct({
name: Schema.String.pipe(Schema.optional),
@@ -100,7 +96,7 @@ const layer = Layer.effect(
source: Source.key(source),
type: source.type,
directories: [],
skills: [source.skill.id],
skills: [source.skill.name],
})
return { skills: [source.skill], directories: [] }
}
@@ -116,13 +112,15 @@ const layer = Layer.effect(
if (!markdown) continue
const frontmatter = decodeFrontmatter(markdown.data).valueOrUndefined
if (!frontmatter) continue
const id =
path.dirname(filepath) === directory
? path.basename(filepath, ".md")
: path.basename(path.dirname(filepath))
const name =
frontmatter.name !== undefined
? frontmatter.name
: path.dirname(filepath) === directory
? path.basename(filepath, ".md")
: undefined
if (!name) continue
skills.push({
id: ID.make(id),
name: Name.make(frontmatter.name ?? id),
name,
description: frontmatter.description,
slash: metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash,
autoinvoke: metadataBoolean(frontmatter.metadata, "opencode/autoinvoke"),
@@ -135,7 +133,7 @@ const layer = Layer.effect(
source: Source.key(source),
type: source.type,
directories,
skills: skills.map((skill) => skill.id),
skills: skills.map((skill) => skill.name),
})
return { skills, directories }
})
@@ -150,7 +148,7 @@ const layer = Layer.effect(
yield* Effect.logInfo("skill cache invalidated", {
file,
sources: invalidated.map(([key]) => key),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.id)),
skills: invalidated.flatMap(([, loaded]) => loaded.skills.map((skill) => skill.name)),
})
yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid)
})
@@ -161,12 +159,12 @@ const layer = Layer.effect(
)
const list = Effect.fn("SkillV2.list")(function* () {
const skills = new Map<ID, Info>()
const skills = new Map<string, Info>()
for (const source of state.get().sources) {
const key = Source.key(source)
const loaded = cache.get(key) ?? (yield* load(source))
cache.set(key, loaded)
for (const skill of loaded.skills) skills.set(skill.id, skill)
for (const skill of loaded.skills) skills.set(skill.name, skill)
}
return Array.from(skills.values())
})
@@ -182,8 +180,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [SkillDiscovery.node, FSUtil.node, EventV2.node],
})
export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node, EventV2.node] })
+6 -8
View File
@@ -8,8 +8,7 @@ import { SkillV2 } from "../skill"
import { Instructions } from "../instructions/index"
const Summary = Schema.Struct({
id: SkillV2.ID,
name: SkillV2.Name,
name: Schema.String,
description: Schema.String,
})
type Summary = typeof Summary.Type
@@ -17,7 +16,6 @@ type Summary = typeof Summary.Type
const entries = (skills: ReadonlyArray<Summary>) =>
skills.flatMap((skill) => [
" <skill>",
` <id>${skill.id}</id>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
" </skill>",
@@ -36,8 +34,8 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
const diff = Instructions.diffByKey(
previous,
current,
(skill) => skill.id,
(before, after) => before.name !== after.name || before.description !== after.description,
(skill) => skill.name,
(before, after) => before.description !== after.description,
)
// Additions and removals render as small deltas; anything else restates the full list.
if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0))
@@ -52,7 +50,7 @@ const update = (previous: ReadonlyArray<Summary>, current: ReadonlyArray<Summary
...(diff.removed.length === 0
? []
: [
`The following skill IDs are no longer available and must not be used: ${diff.removed.map((skill) => skill.id).join(", ")}.`,
`The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`,
]),
].join("\n")
}
@@ -79,9 +77,9 @@ const layer = Layer.effect(
.flatMap((skill) =>
skill.description === undefined || skill.autoinvoke === false
? []
: [{ id: skill.id, name: skill.name, description: skill.description }],
: [{ name: skill.name, description: skill.description }],
)
.toSorted((a, b) => a.id.localeCompare(b.id))
.toSorted((a, b) => a.name.localeCompare(b.name))
return Instructions.make({
key: Instructions.Key.make("core/skill-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
+11 -2
View File
@@ -10,10 +10,10 @@ import { Git } from "./git"
import { Global } from "./global"
import { Location } from "./location"
import { AbsolutePath, RelativePath } from "./schema"
import { ID } from "@opencode-ai/schema/snapshot"
import { Hash } from "./util/hash"
export { ID }
export const ID = Schema.String.pipe(Schema.brand("Snapshot.ID"))
export type ID = typeof ID.Type
export class Error extends Schema.TaggedErrorClass<Error>()("Snapshot.Error", {
operation: Schema.Literals(["capture", "files", "diff", "preview", "restore"]),
@@ -253,3 +253,12 @@ function failure(operation: Error["operation"], cause: unknown) {
cause,
})
}
/** Legacy persisted session diff shape. */
export type LegacyFileDiff = {
file?: string
patch?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}
@@ -1,6 +1,6 @@
export * as PatchTool from "./patch"
export * as ApplyPatchTool from "./apply-patch"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
@@ -55,8 +55,8 @@ type Prepared =
})
export const Plugin = {
id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
id: "opencode.tool.apply-patch",
effect: Effect.fn("ApplyPatchTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service
const fs = yield* FSUtil.Service
@@ -194,19 +194,6 @@ export const Plugin = {
),
)
.pipe(Effect.orDie)
yield* ctx.session.hook("request", (event) =>
Effect.sync(() => {
const usePatch =
event.model.providerID.toLowerCase() === "openai" || event.model.id.toLowerCase().includes("gpt")
if (usePatch) {
delete event.tools.edit
delete event.tools.write
return
}
delete event.tools.patch
}),
)
}),
}
+1 -1
View File
@@ -6,7 +6,7 @@
*/
export * as EditTool from "./edit"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff"
+1 -1
View File
@@ -1,7 +1,7 @@
export * as GlobTool from "./glob"
import { ToolFailure } from "@opencode-ai/llm"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect"
import path from "path"
import { FileSystem } from "../filesystem"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as GrepTool from "./grep"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import path from "path"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as QuestionTool from "./question"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { Form } from "../form"
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ReadTool from "./read"
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { dirname } from "path"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
+4
View File
@@ -191,8 +191,12 @@ const registryLayer = Layer.effect(
const registration = entries.at(-1)?.registration
if (registration) registrations.set(name, registration)
}
// OpenAI/GPT models use patch; every other model uses edit and write.
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
for (const [name, registration] of registrations) {
const wrongEditTool = name === "patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
if (
wrongEditTool ||
(registration.deferred && !Flag.CODEMODE_ENABLED) ||
whollyDisabled(permission(registration.tool, name), input.permissions ?? [])
)

Some files were not shown because too many files have changed in this diff Show More