From 381d67572eb22364dafe7a1015014c2fce34c35e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 29 Jun 2026 11:36:10 -0400 Subject: [PATCH 01/27] refactor(tui): wire generated client reads (#34381) --- bun.lock | 3 + packages/cli/package.json | 1 + packages/cli/src/tui.ts | 19 +- packages/client/src/index.ts | 1 + packages/opencode/package.json | 1 + packages/opencode/src/cli/cmd/attach.ts | 2 + packages/opencode/src/cli/cmd/tui.ts | 2 + packages/tui/package.json | 1 + packages/tui/src/app.tsx | 6 +- .../tui/src/component/dialog-integration.tsx | 106 ++++---- .../tui/src/component/dialog-move-session.tsx | 33 ++- .../tui/src/component/dialog-session-list.tsx | 21 +- .../src/component/dialog-session-rename.tsx | 10 +- packages/tui/src/component/dialog-tag.tsx | 16 +- .../tui/src/component/prompt/autocomplete.tsx | 29 +-- packages/tui/src/component/prompt/index.tsx | 146 ++++++----- packages/tui/src/component/prompt/move.tsx | 19 +- packages/tui/src/context/data.tsx | 141 ++++++----- packages/tui/src/context/sdk.tsx | 19 +- .../tui/src/routes/session/dialog-message.tsx | 12 +- packages/tui/src/routes/session/index.tsx | 228 +++++++++--------- .../tui/src/routes/session/permission.tsx | 8 +- packages/tui/src/routes/session/question.tsx | 10 +- packages/tui/test/app-lifecycle.test.tsx | 41 ++-- .../tui/test/cli/cmd/tui/sync-fixture.tsx | 4 +- packages/tui/test/cli/tui/data.test.tsx | 27 +-- packages/tui/test/cli/tui/use-event.test.tsx | 23 +- packages/tui/test/fixture/tui-sdk.ts | 5 + 28 files changed, 508 insertions(+), 426 deletions(-) diff --git a/bun.lock b/bun.lock index 87361c62e8..48c6ce9af3 100644 --- a/bun.lock +++ b/bun.lock @@ -98,6 +98,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", @@ -581,6 +582,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/client": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -932,6 +934,7 @@ "name": "@opencode-ai/tui", "version": "1.17.11", "dependencies": { + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2ca7a5ad66..52a74be251 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@effect/platform-node": "catalog:", + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/server": "workspace:*", diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 32a225265c..053b99f433 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -3,6 +3,7 @@ import { TuiConfig } from "@opencode-ai/tui/config" import { Effect } from "effect" import { Global } from "@opencode-ai/core/global" import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins" +import { OpenCode } from "@opencode-ai/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" type Transport = { url: string; headers: RequestInit["headers"] } @@ -12,23 +13,23 @@ export function runTui(transport: Transport, reload?: () => Promise) let disposeSlots: (() => void) | undefined return Effect.gen(function* () { const options = { baseUrl: transport.url, headers: transport.headers } - const client = createOpencodeClient(options) - const directory = yield* Effect.tryPromise(() => - client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }), - ).pipe( - Effect.map((response) => response.data.location.directory), + const api = OpenCode.make(options) + const directory = yield* Effect.tryPromise(() => api.files.list({ location: { directory: process.cwd() } })).pipe( + Effect.map((response) => response.location.directory), Effect.catch(() => - Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe( - Effect.map((response) => response.data.directory), - ), + Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)), ), ) return yield* run({ client: createOpencodeClient({ ...options, directory }), + api, reload: reload ? async () => { const next = await reload() - return createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }) + return { + client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }), + api: OpenCode.make({ baseUrl: next.url, headers: next.headers }), + } } : undefined, args: {}, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 6955d7d8c5..82b671ea39 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,2 +1,3 @@ export * from "./generated/index" export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types" +export type OpenCodeClient = ReturnType diff --git a/packages/opencode/package.json b/packages/opencode/package.json index fba8d9b5be..6b6b98bf88 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -84,6 +84,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/client": "workspace:*", "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/protocol": "workspace:*", diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 288bc38ec6..09f227c259 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -3,6 +3,7 @@ import { UI } from "@/cli/ui" import { errorMessage } from "@opencode-ai/tui/util/error" import { validateSession } from "../tui/validate-session" import { ServerAuth } from "@/server/auth" +import { OpenCode } from "@opencode-ai/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2" export const AttachCommand = cmd({ @@ -134,6 +135,7 @@ export const AttachCommand = cmd({ await Effect.runPromise( run({ client: createOpencodeClient({ baseUrl: args.url, headers, directory }), + api: OpenCode.make({ baseUrl: args.url, headers }), config, pluginHost: createLegacyTuiPluginHost(), args: { diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 8063656e68..a661b40396 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -8,6 +8,7 @@ import { errorMessage } from "@opencode-ai/tui/util/error" import { withTimeout } from "@/util/timeout" import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network" import { Filesystem } from "@/util/filesystem" +import { OpenCode } from "@opencode-ai/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { writeHeapSnapshot } from "v8" import { validateSession } from "../tui/validate-session" @@ -205,6 +206,7 @@ export const TuiThreadCommand = cmd({ await Effect.runPromise( run({ client: createOpencodeClient({ baseUrl: url, directory: cwd }), + api: OpenCode.make({ baseUrl: url }), async onSnapshot() { const tui = writeHeapSnapshot("tui.heapsnapshot") const server = await client.call("snapshot", undefined) diff --git a/packages/tui/package.json b/packages/tui/package.json index 1595957190..ba2621716d 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -47,6 +47,7 @@ "./component/spinner": "./src/component/spinner.tsx" }, "dependencies": { + "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 48e609a87f..820a069e83 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -76,6 +76,7 @@ import { useOpencodeKeymap, } from "./keymap" +import type { OpenCodeClient } from "@opencode-ai/client" import type { OpencodeClient } from "@opencode-ai/sdk/v2" import { DialogVariant } from "./component/dialog-variant" import { createTuiAttention } from "./attention" @@ -135,7 +136,8 @@ const appBindingCommands = [ export type TuiInput = { client: OpencodeClient - reload?: () => Promise + api: OpenCodeClient + reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> args: Args config: TuiConfig.Resolved onSnapshot?: () => Promise @@ -290,7 +292,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { > - + diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 0939fedcda..20beb09ded 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -1,5 +1,6 @@ import { TextAttributes } from "@opentui/core" -import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod, IntegrationAttempt } from "@opencode-ai/sdk/v2" +import type { IntegrationsConnectOauthOutput } from "@opencode-ai/client" +import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { useClipboard } from "../context/clipboard" import { useData } from "../context/data" @@ -22,6 +23,7 @@ const INTEGRATION_PRIORITY: Record = { } type ConnectMethod = Exclude +type IntegrationAttempt = IntegrationsConnectOauthOutput["data"] export function integrationOptions(list: IntegrationInfo[]) { return list.toSorted( @@ -109,11 +111,8 @@ function manageConnections( title: `Disconnect ${connection.label}`, value: connection.id, onSelect: () => { - void sdk.client.v2.credential - .remove( - { credentialID: connection.id, location: location(data) }, - { throwOnError: true }, - ) + void sdk.api.credentials + .remove({ credentialID: connection.id, location: location(data) }) .then(() => disconnected(integration.name, data, dialog, toast)) .catch(toast.error) }, @@ -124,11 +123,7 @@ function manageConnections( }) } -function selectMethod( - integration: IntegrationInfo, - methods: ConnectMethod[], - dialog: ReturnType, -) { +function selectMethod(integration: IntegrationInfo, methods: ConnectMethod[], dialog: ReturnType) { if (methods.length === 1) return openMethod(integration, methods[0], dialog) dialog.replace(() => ( , -) { +function openMethod(integration: IntegrationInfo, method: ConnectMethod, dialog: ReturnType) { if (method.type === "key") { dialog.replace(() => ) return @@ -168,21 +159,16 @@ function KeyMethod(props: { integration: IntegrationInfo; method: Extract { if (!key) return - void sdk.client.v2.integration.connect - .key( - { - integrationID: props.integration.id, - location: location(data), - key, - }, - { throwOnError: true }, - ) + void sdk.api.integrations + .connectKey({ + integrationID: props.integration.id, + location: location(data), + key, + }) .then(() => connected(props.integration.name, data, dialog, toast)) .catch((cause) => setError(message(cause))) }} - description={() => ( - {(value) => {value()}} - )} + description={() => {(value) => {value()}}} /> ) } @@ -208,25 +194,22 @@ function OAuthStarting(props: { const toast = useToast() onMount(() => { - void sdk.client.v2.integration.connect - .oauth( - { - integrationID: props.integration.id, - location: location(data), - methodID: props.method.id, - inputs: props.inputs, - }, - { throwOnError: true }, - ) + void sdk.api.integrations + .connectOauth({ + integrationID: props.integration.id, + location: location(data), + methodID: props.method.id, + inputs: props.inputs, + }) .then((result) => { - if (result.data.data.mode === "code") { + if (result.data.mode === "code") { dialog.replace(() => ( - + )) return } dialog.replace(() => ( - + )) }) .catch((cause) => { @@ -265,10 +248,10 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt })) const poll = () => { - void sdk.client.v2.integration.attempt - .status({ attemptID: props.attempt.attemptID, location: location(data) }, { throwOnError: true }) + void sdk.api.integrations + .attemptStatus({ attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { - const status = result.data.data + const status = result.data if (status.status === "pending") { timer = setTimeout(poll, 500) return @@ -292,7 +275,7 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt onCleanup(() => { if (timer) clearTimeout(timer) if (settled) return - void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integrations.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) }) return ( @@ -317,7 +300,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt onCleanup(() => { if (settled) return - void sdk.client.v2.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integrations.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) }) return ( @@ -326,11 +309,8 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt placeholder="Authorization code" onConfirm={(code) => { if (!code) return - void sdk.client.v2.integration.attempt - .complete( - { attemptID: props.attempt.attemptID, location: location(data), code }, - { throwOnError: true }, - ) + void sdk.api.integrations + .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true return connected(props.integration.name, data, dialog, toast) @@ -348,13 +328,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt ) } -function OAuthView(props: { - title: string - url?: string - instructions?: string - message: string - copy?: boolean -}) { +function OAuthView(props: { title: string; url?: string; instructions?: string; message: string; copy?: boolean }) { const dialog = useDialog() const { theme } = useTheme() return ( @@ -371,7 +345,9 @@ function OAuthView(props: { {(url) => ( - {(instructions) => {instructions()}} + + {(instructions) => {instructions()}} + )} @@ -436,7 +412,11 @@ async function connected( dialog: ReturnType, toast: ReturnType, ) { - await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()]) + await Promise.all([ + data.location.integration.refresh(), + data.location.model.refresh(), + data.location.provider.refresh(), + ]) toast.show({ variant: "success", message: `Connected ${name}` }) dialog.clear() } @@ -447,7 +427,11 @@ async function disconnected( dialog: ReturnType, toast: ReturnType, ) { - await Promise.all([data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh()]) + await Promise.all([ + data.location.integration.refresh(), + data.location.model.refresh(), + data.location.provider.refresh(), + ]) toast.show({ variant: "success", message: `Disconnected ${name}` }) dialog.clear() } diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index b4fb2f2b45..200c18d97a 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -11,6 +11,7 @@ import { abbreviateHome } from "../runtime" import { useTuiPaths } from "../context/runtime" import { Locale } from "../util/locale" import { errorMessage } from "../util/error" +import { isRecord } from "../util/record" import { useToast } from "../ui/toast" import { useCommandShortcut } from "../keymap" import { useProject } from "../context/project" @@ -74,10 +75,10 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { () => (props.initialRemoving ? undefined : props.projectID), async (projectID, info): Promise => { try { - await sdk.client.v2.projectCopy.refresh( - { projectID, location: { directory: projectContext.instance.directory() || paths.cwd } }, - { throwOnError: true }, - ) + await sdk.api.projectCopies.refresh({ + projectID, + location: { directory: projectContext.instance.directory() || paths.cwd }, + }) const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) setLoadError(undefined) return directories.data ?? [] @@ -221,18 +222,21 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { setToDelete(undefined) setRemoving(selected.directory) setWorking(true) - const result = await sdk.client.v2.projectCopy + const error = await sdk.api.projectCopies .remove({ projectID: props.projectID, location: { directory: projectContext.instance.directory() || paths.cwd }, directory: selected.directory, force: false, }) - .catch((error) => ({ error })) - if (result.error) { + .then( + () => undefined, + (error) => error, + ) + if (error) { setRemoving(undefined) setWorking(false) - if ("data" in result.error && result.error.data.forceRequired) { + if (isRecord(error) && isRecord(error.data) && error.data.forceRequired === true) { const status = await sdk.client.vcs.status({ directory: selected.directory }).catch(() => undefined) const choice = await DialogWorkspaceFileChanges.show(dialog, status?.data ?? [], { title: "Delete working copy?", @@ -243,19 +247,22 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { return } reopen(selected.directory) - const forced = await sdk.client.v2.projectCopy + const forcedError = await sdk.api.projectCopies .remove({ projectID: props.projectID, location: { directory: projectContext.instance.directory() || paths.cwd }, directory: selected.directory, force: true, }) - .catch((error) => ({ error })) - if (forced.error) { + .then( + () => undefined, + (error) => error, + ) + if (forcedError) { toast.show({ variant: "error", title: "Failed to delete project copy", - message: errorMessage(forced.error), + message: errorMessage(forcedError), }) reopen() return @@ -269,7 +276,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { toast.show({ variant: "error", title: "Failed to delete project copy", - message: errorMessage(result.error), + message: errorMessage(error), }) return } diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 771ab0eca5..fc1915e398 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -31,11 +31,16 @@ export function DialogSessionList() { const [searchResults] = createResource(search, async (query) => { if (!query) return - const response = await sdk.client.v2.session.list( - { search: query, limit: 50, order: "desc" }, - { throwOnError: true }, - ) - return { query, sessions: response.data.data } + const location = data.location.default() + const response = await sdk.api.sessions.list({ + search: query, + limit: 50, + order: "desc", + directory: location.directory, + workspace: location.workspaceID, + }) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; session list UI reuses legacy mutable session types. + return { query, sessions: structuredClone(response.data) as SessionV2Info[] } }) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) @@ -59,7 +64,11 @@ export function DialogSessionList() { const options = createMemo(() => { const today = new Date().toDateString() - const sessionMap = new Map(sessions().filter((session) => !session.parentID).map((session) => [session.id, session])) + const sessionMap = new Map( + sessions() + .filter((session) => !session.parentID) + .map((session) => [session.id, session]), + ) const pinned = local.session.pinned().filter((sessionID) => sessionMap.has(sessionID)) const pinnedSet = new Set(pinned) const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1])) diff --git a/packages/tui/src/component/dialog-session-rename.tsx b/packages/tui/src/component/dialog-session-rename.tsx index 72dfb8b11d..1b9e3372cd 100644 --- a/packages/tui/src/component/dialog-session-rename.tsx +++ b/packages/tui/src/component/dialog-session-rename.tsx @@ -17,11 +17,15 @@ export function DialogSessionRename(props: { sessionID: string; currentTitle?: s onConfirm={(value) => { const title = value.trim() if (!title) return - void sdk.client.v2.session - .rename({ sessionID: props.sessionID, title }, { throwOnError: true }) + void sdk.api.sessions + .rename({ sessionID: props.sessionID, title }) .then(() => dialog.clear()) .catch((error) => - toast.show({ message: `Failed to rename session: ${errorMessage(error)}`, variant: "error", duration: 5000 }), + toast.show({ + message: `Failed to rename session: ${errorMessage(error)}`, + variant: "error", + duration: 5000, + }), ) }} onCancel={() => dialog.clear()} diff --git a/packages/tui/src/component/dialog-tag.tsx b/packages/tui/src/component/dialog-tag.tsx index 39560d5eac..2b3dc83524 100644 --- a/packages/tui/src/component/dialog-tag.tsx +++ b/packages/tui/src/component/dialog-tag.tsx @@ -17,13 +17,15 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) { const [files] = createResource( () => [store.filter], async () => { - const result = await sdk.client.find.files({ - query: store.filter, - workspace: project.workspace.current(), - }) - if (result.error) return [] - const sliced = (result.data ?? []).slice(0, 5) - return sliced + const result = await sdk.api.files + .find({ + query: store.filter, + type: "file", + limit: 5, + location: { workspace: project.workspace.current() }, + }) + .catch(() => undefined) + return result?.data.map((item) => item.path) ?? [] }, ) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 28ebef05dc..38e387e499 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -320,29 +320,26 @@ export function Autocomplete(props: { if (referenceMatch()) return [] const { lineRange, baseQuery } = extractLineRange(input.query ?? "") - // Get files from SDK - const result = await sdk.client.v2.fs.find({ - query: baseQuery, - limit: "20", - location: { - directory: input.location?.directory, - workspace: input.location?.workspaceID ?? project.workspace.current(), - }, - }) + const result = await sdk.api.files + .find({ + query: baseQuery, + limit: 20, + location: { + directory: input.location?.directory, + workspace: input.location?.workspaceID ?? project.workspace.current(), + }, + }) + .catch(() => undefined) const options: AutocompleteOption[] = [] // Add file options. Trust the order returned by fff (frecency, fuzzy // score, filename bonus, etc. are already factored in). - if (!result.error && result.data) { + if (result) { const width = props.anchor().width - 4 options.push( - ...result.data.data.map((item): AutocompleteOption => { - const { filename, part } = createFilePart( - item, - path.join(result.data.location.directory, item.path), - lineRange, - ) + ...result.data.map((item): AutocompleteOption => { + const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange) return { display: Locale.truncateMiddle(filename, width), value: filename, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index f1082fef5e..f868f33679 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -37,7 +37,7 @@ import { usePromptStash } from "../../prompt/stash" import { DialogStash } from "../dialog-stash" import { type AutocompleteRef, Autocomplete } from "./autocomplete" import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { AssistantMessage, FilePart, UserMessage } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, FilePart, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2" import { Locale } from "../../util/locale" import { errorMessage } from "../../util/error" import { createColors, createFrames } from "../../ui/spinner" @@ -158,11 +158,12 @@ export function Prompt(props: PromptProps) { const dialog = useDialog() const toast = useToast() const status = createMemo(() => data.session.status(props.sessionID ?? "")) - const activeSubagents = createMemo(() => - data.session - .list() - .filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running") - .length, + const activeSubagents = createMemo( + () => + data.session + .list() + .filter((session) => session.parentID === props.sessionID && data.session.status(session.id) === "running") + .length, ) const runningShells = createMemo( () => data.shell.list().filter((shell) => shell.metadata.sessionID === props.sessionID).length, @@ -424,7 +425,7 @@ export function Prompt(props: PromptProps) { }, 5000) if (store.interrupt >= 2) { - void sdk.client.v2.session.interrupt({ + void sdk.api.sessions.interrupt({ sessionID: props.sessionID, }) setStore("interrupt", 0) @@ -1009,18 +1010,23 @@ export function Prompt(props: PromptProps) { const directory = await move.getDirectory(store.prompt.input) if (move.pending() && !directory) return false finishMoveProgress = Boolean(move.progress()) + const location = data.location.default() - const res = await sdk.client.v2.session.create({ - location: directory ? { directory, workspaceID } : undefined, - agent: agent.id, - model: { - providerID: selectedModel.providerID, - id: selectedModel.modelID, - variant, - }, - }) + const created = await sdk.api.sessions + .create({ + location: directory + ? { directory, workspaceID } + : { directory: location.directory, workspaceID: workspaceID ?? location.workspaceID }, + agent: agent.id, + model: { + providerID: selectedModel.providerID, + id: selectedModel.modelID, + variant, + }, + }) + .catch(() => undefined) - if (res.error) { + if (!created) { if (finishMoveProgress) move.finishSubmit() toast.show({ message: "Creating a session failed. Open console for more details.", @@ -1030,8 +1036,9 @@ export function Prompt(props: PromptProps) { return true } - sessionID = res.data.data.id - session = res.data.data + sessionID = created.id + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; prompt state still uses legacy mutable session types. + session = structuredClone(created) as SessionV2Info } const inputText = expandTrackedPastedText( @@ -1107,65 +1114,70 @@ export function Prompt(props: PromptProps) { session = data.session.get(sessionID) } if (session?.agent !== agent.id) { - await sdk.client.v2.session.switchAgent({ sessionID, agent: agent.id }, { throwOnError: true }) + await sdk.api.sessions.switchAgent({ sessionID, agent: agent.id }) } if ( session?.model?.providerID !== selectedModel.providerID || session.model.id !== selectedModel.modelID || session.model.variant !== variant ) { - await sdk.client.v2.session.switchModel( - { - sessionID, - model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, - }, - { throwOnError: true }, - ) + await sdk.api.sessions.switchModel({ + sessionID, + model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, + }) } if (session?.revert) { - const revertResult = await sdk.client.v2.session.revert.commit({ sessionID }) - if (revertResult.error) { - toast.show({ title: "Failed to commit revert", message: errorMessage(revertResult.error), variant: "error" }) + const error = await sdk.api.sessions.commit({ sessionID }).then( + () => undefined, + (error) => error, + ) + if (error) { + toast.show({ title: "Failed to commit revert", message: errorMessage(error), variant: "error" }) return false } } - const result = await sdk.client.v2.session.prompt({ - sessionID, - prompt: { - text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), - files: nonTextParts.flatMap((part) => - part.type === "file" - ? [ - { - uri: part.url, - name: part.filename, - source: part.source - ? { - start: part.source.text.start, - end: part.source.text.end, - text: part.source.text.value, - } - : undefined, - }, - ] - : [], - ), - agents: nonTextParts.flatMap((part) => - part.type === "agent" - ? [ - { - name: part.name, - source: part.source - ? { start: part.source.start, end: part.source.end, text: part.source.value } - : undefined, - }, - ] - : [], - ), - }, - }) - if (result.error) { - toast.show({ title: "Failed to send prompt", message: errorMessage(result.error), variant: "error" }) + const error = await sdk.api.sessions + .prompt({ + sessionID, + prompt: { + text: [...editorParts.map((part) => part.text), inputText].filter(Boolean).join("\n\n"), + files: nonTextParts.flatMap((part) => + part.type === "file" + ? [ + { + uri: part.url, + name: part.filename, + source: part.source + ? { + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + } + : undefined, + }, + ] + : [], + ), + agents: nonTextParts.flatMap((part) => + part.type === "agent" + ? [ + { + name: part.name, + source: part.source + ? { start: part.source.start, end: part.source.end, text: part.source.value } + : undefined, + }, + ] + : [], + ), + }, + }) + .then( + () => undefined, + (error) => error, + ) + if (error) { + toast.show({ title: "Failed to send prompt", message: errorMessage(error), variant: "error" }) return false } if (editorParts.length > 0) editor.markSelectionSent() diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index 40956a148e..b567fd97f4 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -37,17 +37,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess { projectID, context }, { throwOnError: true }, ) - const result = await sdk.client.v2.projectCopy.create( - { - projectID, - location: { directory: project.instance.directory() || paths.cwd }, - strategy: "git_worktree", - directory: path.join(paths.worktree, projectID.slice(0, 6)), - name: generated.data.name, - }, - { throwOnError: true }, - ) - const directory = result.data?.directory + const result = await sdk.api.projectCopies.create({ + projectID, + location: { directory: project.instance.directory() || paths.cwd }, + strategy: "git_worktree", + directory: path.join(paths.worktree, projectID.slice(0, 6)), + name: generated.data.name, + }) + const directory = result.directory if (!directory) throw new Error("No project copy directory returned") // Call a location-based route to make sure it's bootstrapped diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index df3220f501..32a673f506 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -61,6 +61,14 @@ function locationQuery(ref?: LocationRef) { return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined } +type Mutable = + T extends ReadonlyArray ? Mutable[] : T extends object ? { -readonly [K in keyof T]: Mutable } : T + +function mutable(value: T): Mutable { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client data is readonly; the TUI store mutates cloned state. + return structuredClone(value) as Mutable +} + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: () => { @@ -288,13 +296,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.text.delta": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID) + const match = message.latestText( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.textID, + ) if (match) match.text += event.data.delta }) break case "session.next.text.ended": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID), event.data.textID) + const match = message.latestText( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.textID, + ) if (match) match.text = event.data.text }) break @@ -311,19 +325,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.input.delta": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (match?.state.status === "pending") match.state.input += event.data.delta }) break case "session.next.tool.input.ended": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (match?.state.status === "pending") match.state.input = event.data.text }) break case "session.next.tool.called": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (!match) return match.time.ran = event.data.timestamp match.provider = event.data.provider @@ -332,7 +355,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.progress": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (match?.state.status !== "running") return match.state.structured = event.data.structured match.state.content = [...event.data.content] @@ -340,7 +366,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.success": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (match?.state.status !== "running") return match.state = { status: "completed", @@ -359,7 +388,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ break case "session.next.tool.failed": message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool(message.assistant(draft, index, event.data.assistantMessageID), event.data.callID) + const match = message.latestTool( + message.assistant(draft, index, event.data.assistantMessageID), + event.data.callID, + ) if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return match.state = { status: "error", @@ -522,8 +554,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.status[sessionID] ?? "idle" }, async refresh(sessionID: string) { - const result = await sdk.client.v2.session.get({ sessionID }, { throwOnError: true }) - setStore("session", "info", sessionID, result.data.data) + setStore("session", "info", sessionID, mutable(await sdk.api.sessions.get({ sessionID }))) }, message: { ids(sessionID: string) { @@ -540,11 +571,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async refresh(sessionID: string) { setStore("session", "message", sessionID, []) messageIndex.set(sessionID, new Map()) - const response = await sdk.client.v2.session.messages( - { sessionID, limit: 200, order: "desc" }, - { throwOnError: true }, - ) - const loaded = response.data.data.toReversed() + const loaded = mutable( + (await sdk.api.messages.list({ sessionID, limit: 200, order: "desc" })).data, + ).toReversed() const live = store.session.message[sessionID] ?? [] const liveByID = new Map(live.map((message) => [message.id, message])) const messages = [...loaded.map((message) => liveByID.get(message.id) ?? message), ...live] @@ -559,8 +588,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.permission[sessionID] }, async refresh(sessionID: string) { - const result = await sdk.client.v2.session.permission.list({ sessionID }, { throwOnError: true }) - setStore("session", "permission", sessionID, result.data.data) + setStore("session", "permission", sessionID, mutable(await sdk.api.permissions.list({ sessionID }))) }, }, question: { @@ -568,8 +596,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.question[sessionID] }, async refresh(sessionID: string) { - const result = await sdk.client.v2.session.question.list({ sessionID }, { throwOnError: true }) - setStore("session", "question", sessionID, result.data.data) + setStore("session", "question", sessionID, mutable(await sdk.api.questions.list({ sessionID }))) }, }, }, @@ -579,8 +606,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.project.permission[projectID] }, async refresh(projectID: string) { - const result = await sdk.client.v2.permission.saved.list({ projectID }, { throwOnError: true }) - setStore("project", "permission", projectID, result.data.data) + setStore("project", "permission", projectID, mutable(await sdk.api.permissions.listSaved({ projectID }))) }, }, }, @@ -606,8 +632,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return defaultLocation() }, async refresh(ref?: LocationRef) { - const response = await sdk.client.v2.location.get({ location: locationQuery(ref) }, { throwOnError: true }) - const location = response.data + const location = await sdk.api.location.get({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(location) if (!store.location[key]) setStore("location", key, {}) if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) @@ -617,9 +642,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.agent }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.agent.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], agent: result.data.data }) + const result = await sdk.api.agents.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], agent: mutable(result.data) }) }, }, command: { @@ -627,9 +652,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.command }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.command.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], command: result.data.data }) + const result = await sdk.api.commands.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], command: mutable(result.data) }) }, }, integration: { @@ -637,12 +662,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.integration }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.integration.list( - { location: locationQuery(ref) }, - { throwOnError: true }, - ) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], integration: result.data.data }) + const result = await sdk.api.integrations.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], integration: mutable(result.data) }) }, }, model: { @@ -650,9 +672,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.model }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.model.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], model: result.data.data }) + const result = await sdk.api.models.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], model: mutable(result.data) }) }, }, provider: { @@ -660,9 +682,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.provider }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.provider.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], provider: result.data.data }) + const result = await sdk.api.providers.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], provider: mutable(result.data) }) }, }, reference: { @@ -670,9 +692,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.reference }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.reference.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], reference: result.data.data }) + const result = await sdk.api.references.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], reference: mutable(result.data) }) }, }, skill: { @@ -680,9 +702,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.skill }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.skill.list({ location: locationQuery(ref) }, { throwOnError: true }) - const key = locationKey(result.data.location) - setStore("location", key, { ...store.location[key], skill: result.data.data }) + const result = await sdk.api.skills.list({ location: locationQuery(ref ?? defaultLocation()) }) + const key = locationKey(result.location) + setStore("location", key, { ...store.location[key], skill: mutable(result.data) }) }, }, }, @@ -690,24 +712,31 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async function bootstrap() { const settled = await Promise.allSettled([ - sdk.client.v2.session - .list({ limit: 50, order: "desc" }, { throwOnError: true }) + sdk.api.sessions + .list({ + limit: 50, + order: "desc", + directory: defaultLocation().directory, + workspace: defaultLocation().workspaceID, + }) .then((response) => setStore( "session", "info", produce((draft) => { - for (const session of response.data.data) draft[session.id] = session + for (const session of response.data) draft[session.id] = mutable(session) }), ), ), - sdk.client.v2.session.active({ throwOnError: true }).then((response) => - setStore( - "session", - "status", - Object.fromEntries(Object.keys(response.data.data).map((sessionID) => [sessionID, "running" as const])), + sdk.api.sessions + .active() + .then((active) => + setStore( + "session", + "status", + Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const])), + ), ), - ), result.location.refresh(), result.location.agent.refresh(), result.location.integration.refresh(), diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 751f57bd13..6750419cfd 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -1,3 +1,4 @@ +import type { OpenCodeClient } from "@opencode-ai/client" import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { onCleanup, onMount } from "solid-js" @@ -11,9 +12,14 @@ const connectTimeout = 2_000 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", - init: (props: { client: OpencodeClient; reload?: () => Promise }) => { + init: (props: { + client: OpencodeClient + api: OpenCodeClient + reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }> + }) => { const abort = new AbortController() let client = props.client + let api = props.api const events = createGlobalEmitter() const [connection, setConnection] = createStore<{ status: SDKConnectionStatus @@ -40,7 +46,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ while (!abort.signal.aborted && !controller.signal.aborted) { const connection = new AbortController() const cancel = () => connection.abort(controller.signal.reason) - const timeout = setTimeout(() => connection.abort(new Error("Timed out connecting to server")), connectTimeout) + const timeout = setTimeout( + () => connection.abort(new Error("Timed out connecting to server")), + connectTimeout, + ) controller.signal.addEventListener("abort", cancel, { once: true }) const error = await (async () => { const response = await current.v2.event.subscribe({ @@ -91,7 +100,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ pending = Promise.resolve() .then(props.reload) .then(async (next) => { - client = next + client = next.client + api = next.api if (!abort.signal.aborted) await start() }) .finally(() => { @@ -112,6 +122,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ get client() { return client }, + get api() { + return api + }, event: { on: events.on, listen: events.listen, diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index c7c1a7c373..2fa53ef377 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -11,9 +11,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set const clipboard = useClipboard() const toast = useToast() const sdk = useSDK() - const message = createMemo(() => - data.session.message.get(props.sessionID, props.messageID), - ) + const message = createMemo(() => data.session.message.get(props.sessionID, props.messageID)) return ( { - const result = await sdk.client.v2.session.revert.stage({ - sessionID: props.sessionID, - messageID: props.messageID, - }) - if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) + await sdk.api.sessions + .stage({ sessionID: props.sessionID, messageID: props.messageID }) + .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) dialog.clear() }, }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 8a3c8bffd8..3c521a6b1e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -156,10 +156,11 @@ export function Session() { const promptRef = usePromptRef() const session = createMemo(() => data.session.get(route.sessionID)) const messageIDs = createMemo(() => data.session.message.ids(route.sessionID)) - const sessionMessages = () => messageIDs().flatMap((id) => { - const message = data.session.message.get(route.sessionID, id) - return message ? [message] : [] - }) + const sessionMessages = () => + messageIDs().flatMap((id) => { + const message = data.session.message.get(route.sessionID, id) + return message ? [message] : [] + }) const location = createMemo(() => session()?.location) createEffect(() => { @@ -286,7 +287,10 @@ export function Session() { if (!message) return false if (message.type === "user") return Boolean(message.text.trim()) - return message.type === "assistant" && message.content.some((content) => content.type === "text" && content.text.trim()) + return ( + message.type === "assistant" && + message.content.some((content) => content.type === "text" && content.text.trim()) + ) }) .sort((a, b) => a.y - b.y) @@ -361,7 +365,7 @@ export function Session() { aliases: ["summarize"], }, run: () => { - void sdk.client.v2.session.compact({ sessionID: route.sessionID }) + void sdk.api.sessions.compact({ sessionID: route.sessionID }) dialog.clear() }, }, @@ -395,8 +399,11 @@ export function Session() { dialog.clear() return } - const result = await sdk.client.v2.session.revert.stage({ sessionID: route.sessionID, messageID: target }) - if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) + const error = await sdk.api.sessions.stage({ sessionID: route.sessionID, messageID: target }).then( + () => undefined, + (error) => error, + ) + if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) dialog.clear() })() }, @@ -409,8 +416,11 @@ export function Session() { slash: { name: "redo" }, run: () => { void (async () => { - const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID }) - if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) + const error = await sdk.api.sessions.clear({ sessionID: route.sessionID }).then( + () => undefined, + (error) => error, + ) + if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) dialog.clear() })() }, @@ -878,23 +888,21 @@ export function Session() { message.id >= session()!.revert!.messageID && message.type === "user").length} + count={ + messages().filter( + (message) => message.id >= session()!.revert!.messageID && message.type === "user", + ).length + } files={session()!.revert!.files ?? []} /> 0}> - + 0}> - + @@ -957,9 +965,7 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) = {(row) => ( - - {(message) => } - + {(message) => } )} @@ -1073,8 +1079,8 @@ function SessionGroupView(props: { result[name] = (result[name] ?? 0) + 1 return result }, {}) - const tools = Object.entries(counts).map(([name, count]) => - `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`, + const tools = Object.entries(counts).map( + ([name, count]) => `${count} ${count === 1 ? name : name === "search" ? "searches" : `${name}s`}`, ) return `${props.completed ? "Explored" : "Exploring"} — ${tools.join(", ")}` }) @@ -1116,9 +1122,10 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { const { theme } = useTheme() const model = createMemo( () => - ctx.models().find( - (model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id, - )?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, + ctx + .models() + .find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id) + ?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, ) const duration = createMemo(() => props.message.time.completed ? props.message.time.completed - props.message.time.created : 0, @@ -1158,14 +1165,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) { function CompactionMessage() { const { theme } = useTheme() - return ( - - ) + return } function statusLabel(status: "added" | "modified" | "deleted") { @@ -1197,8 +1197,11 @@ function RevertMessage(props: { onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return void (async () => { - const result = await sdk.client.v2.session.revert.clear({ sessionID: route.sessionID }) - if (result.error) toast.show({ message: errorMessage(result.error), variant: "error", duration: 5000 }) + const error = await sdk.api.sessions.clear({ sessionID: route.sessionID }).then( + () => undefined, + (error) => error, + ) + if (error) toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }) })() }} flexShrink={0} @@ -1207,7 +1210,12 @@ function RevertMessage(props: { customBorderChars={SplitBorder.customBorderChars} borderColor={theme.backgroundPanel} > - + {props.count} message{props.count === 1 ? "" : "s"} reverted @@ -1251,54 +1259,63 @@ function UserMessage(props: { message: SessionMessageUser }) { return ( + { + setHover(true) + }} + onMouseOut={() => { + setHover(false) + }} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ) + }} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} + flexShrink={0} > - { - setHover(true) - }} - onMouseOut={() => { - setHover(false) - }} - onMouseUp={() => { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ) - }} - paddingTop={1} - paddingBottom={1} - paddingLeft={2} - backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel} - flexShrink={0} - > - {props.message.text} - - - - {(file) => { - const directory = file.mime === "application/x-directory" - return ( - - - {directory ? " Directory " : " File "} - - {file.name ?? file.uri} - - ) - }} - - - - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - - + {props.message.text} + + + + {(file) => { + const directory = file.mime === "application/x-directory" + return ( + + + {directory ? " Directory " : " File "} + + + {" "} + {file.name ?? file.uri}{" "} + + + ) + }} + + + + + + {Locale.todayTimeOrDateTime(props.message.time.created)} + + + ) } @@ -1309,9 +1326,10 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole const { theme } = useTheme() const model = createMemo( () => - ctx.models().find( - (model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id, - )?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, + ctx + .models() + .find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id) + ?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, ) const final = createMemo(() => { @@ -1325,10 +1343,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole }) const exploration = createMemo(() => { - const grouped = new Map< - string, - { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean } - >() + const grouped = new Map() if (!ctx.groupExploration()) return grouped const runs = props.message.content .map((part) => @@ -1471,7 +1486,9 @@ function ReasoningPart(props: { // OpenRouter encrypts some reasoning blocks; drop the placeholder. return props.part.text.replace("[REDACTED]", "").trim() }) - const isDone = createMemo(() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined) + const isDone = createMemo( + () => props.part.time?.completed !== undefined || props.message.time.completed !== undefined, + ) const inMinimal = createMemo(() => ctx.thinkingMode() === "hide") const duration = createMemo(() => { const end = props.part.time?.completed ?? props.message.time.completed @@ -1488,11 +1505,7 @@ function ReasoningPart(props: { return ( - + void }) { return ( - + @@ -2003,12 +2011,7 @@ function Write(props: ToolProps) { - + Write {pathFormatter.format(stringValue(props.input.path))} @@ -2473,7 +2476,14 @@ export function parseApplyPatchFiles(value: unknown) { const patch = stringValue(file.patch) const additions = numberValue(file.additions) const deletions = numberValue(file.deletions) - if (!type || !relativePath || !filePath || patch === undefined || additions === undefined || deletions === undefined) + if ( + !type || + !relativePath || + !filePath || + patch === undefined || + additions === undefined || + deletions === undefined + ) return [] return [{ type, relativePath, filePath, patch, additions, deletions, movePath: stringValue(file.movePath) }] }) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 91e2189a18..35230f1160 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -186,7 +186,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director onSelect={(option) => { setStore("stage", "permission") if (option === "cancel") return - void sdk.client.v2.session.permission.reply({ + void sdk.api.permissions.reply({ sessionID: props.request.sessionID, reply: "always", requestID: props.request.id, @@ -197,7 +197,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director { - void sdk.client.v2.session.permission.reply({ + void sdk.api.permissions.reply({ sessionID: props.request.sessionID, reply: "reject", requestID: props.request.id, @@ -443,14 +443,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director setStore("stage", "reject") return } - void sdk.client.v2.session.permission.reply({ + void sdk.api.permissions.reply({ sessionID: props.request.sessionID, reply: "reject", requestID: props.request.id, }) return } - void sdk.client.v2.session.permission.reply({ + void sdk.api.permissions.reply({ sessionID: props.request.sessionID, reply: "once", requestID: props.request.id, diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index ded2c6db4f..9bbabaaaee 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -47,15 +47,15 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: function submit() { const answers = questions().map((_, i) => store.answers[i] ?? []) - void sdk.client.v2.session.question.reply({ + void sdk.api.questions.reply({ sessionID: props.request.sessionID, requestID: props.request.id, - questionV2Reply: { answers }, + answers, }) } function reject() { - void sdk.client.v2.session.question.reject({ + void sdk.api.questions.reject({ sessionID: props.request.sessionID, requestID: props.request.id, }) @@ -71,10 +71,10 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: setStore("custom", inputs) } if (single()) { - void sdk.client.v2.session.question.reply({ + void sdk.api.questions.reply({ sessionID: props.request.sessionID, requestID: props.request.id, - questionV2Reply: { answers: [[answer]] }, + answers: [[answer]], }) return } diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 019cf06991..9ee2742894 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -4,7 +4,7 @@ import { createTestRenderer } from "@opentui/core/testing" import { Effect } from "effect" import { Global } from "@opencode-ai/core/global" import { createTuiResolvedConfig } from "./fixture/tui-runtime" -import { createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk" +import { createApi, createClient, createEventStream, createFetch, directory, json } from "./fixture/tui-sdk" test("SIGHUP clears title and disposes scoped resources once", async () => { const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) @@ -30,6 +30,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => { const task = Effect.runPromise( run({ client: createClient(calls.fetch), + api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), args: {}, pluginHost: { @@ -61,26 +62,23 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { const core = await import("@opentui/core") mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer })) const events = createEventStream() - const calls = createFetch( - (url) => { - if (url.pathname === "/api/session") - return json({ - data: [ - { - id: "dummy", - title: "Demo session", - projectID: "project", - location: { directory }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 0, updated: 0 }, - }, - ], - cursor: {}, - }) - }, - events, - ) + const calls = createFetch((url) => { + if (url.pathname === "/api/session") + return json({ + data: [ + { + id: "dummy", + title: "Demo session", + projectID: "project", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + }, + ], + cursor: {}, + }) + }, events) const originalWrite = process.stdout.write.bind(process.stdout) let stdout = "" let api: TuiPluginApi | undefined @@ -99,6 +97,7 @@ test("app.exit prints the session epilogue after scoped cleanup", async () => { const task = Effect.runPromise( run({ client: createClient(calls.fetch), + api: createApi(calls.fetch), config: createTuiResolvedConfig({ plugin_enabled: {} }), args: { continue: true }, pluginHost: { diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index f658939bb9..d340c8558e 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -7,7 +7,7 @@ import { ProjectProvider, useProject } from "../../../../src/context/project" import { SDKProvider } from "../../../../src/context/sdk" import { SyncProvider, useSync } from "../../../../src/context/sync" import { ExitProvider } from "../../../../src/context/exit" -import { createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk" +import { createApi, createClient, createEventStream, createFetch, type FetchHandler } from "../../../fixture/tui-sdk" import { TestTuiContexts } from "../../../fixture/tui-environment" export { createEventStream, createFetch, directory, json, worktree } from "../../../fixture/tui-sdk" @@ -47,7 +47,7 @@ export async function mount(override?: FetchHandler, state?: string) { - + {}}> diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 060f3de1a2..50f5306c21 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -6,7 +6,7 @@ import { onMount } from "solid-js" import { ProjectProvider } from "../../../src/context/project" import { SDKProvider } from "../../../src/context/sdk" import { DataProvider, useData } from "../../../src/context/data" -import { createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk" +import { createApi, createClient, createEventStream, createFetch, directory, json } from "../../fixture/tui-sdk" import { TestTuiContexts } from "../../fixture/tui-environment" async function wait(fn: () => boolean, timeout = 2000) { @@ -69,7 +69,7 @@ test("refreshes resources into reactive getters", async () => { const app = await testRender(() => ( - + @@ -139,7 +139,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => { const app = await testRender(() => ( - + @@ -172,8 +172,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => { test("tracks session status from active sessions and execution events", async () => { const events = createEventStream() const calls = createFetch((url) => { - if (url.pathname === "/api/session/active") - return json({ data: { "session-active": { type: "running" } } }) + if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } }) }, events) let data!: ReturnType @@ -184,7 +183,7 @@ test("tracks session status from active sessions and execution events", async () const app = await testRender(() => ( - + @@ -296,7 +295,7 @@ test("refreshes integrations after integration updates", async () => { const app = await testRender(() => ( - + @@ -337,7 +336,7 @@ test("refreshes effective catalog data after catalog updates", async () => { const app = await testRender(() => ( - + @@ -382,7 +381,7 @@ test("refreshes references after updates", async () => { const app = await testRender(() => ( - + @@ -415,7 +414,7 @@ test("adds and dismisses permission requests from live events", async () => { const app = await testRender(() => ( - + @@ -480,7 +479,7 @@ test("adds and dismisses question requests from live events", async () => { const app = await testRender(() => ( - + @@ -548,7 +547,7 @@ test("settles pending tools when a live failure arrives", async () => { const app = await testRender(() => ( - + @@ -677,7 +676,7 @@ test("renders admitted prompts only after they become model-visible", async () = const app = await testRender(() => ( - + @@ -750,7 +749,7 @@ test("projects live context updates with their message ID", async () => { const app = await testRender(() => ( - + diff --git a/packages/tui/test/cli/tui/use-event.test.tsx b/packages/tui/test/cli/tui/use-event.test.tsx index 108bf940c9..ccf5be2fcb 100644 --- a/packages/tui/test/cli/tui/use-event.test.tsx +++ b/packages/tui/test/cli/tui/use-event.test.tsx @@ -1,12 +1,13 @@ /** @jsxImportSource @opentui/solid */ import { describe, expect, test } from "bun:test" +import type { OpenCodeClient } from "@opencode-ai/client" import { testRender } from "@opentui/solid" import type { OpencodeClient, V2Event } from "@opencode-ai/sdk/v2" import { onMount } from "solid-js" import { ProjectProvider, useProject } from "../../../src/context/project" import { SDKProvider, useSDK } from "../../../src/context/sdk" import { useEvent } from "../../../src/context/event" -import { createClient, createEventStream, createFetch, directory } from "../../fixture/tui-sdk" +import { createApi, createClient, createEventStream, createFetch } from "../../fixture/tui-sdk" import { TestTuiContexts } from "../../fixture/tui-environment" const projectID = "proj_test" @@ -46,7 +47,7 @@ function update(version: string): V2Event { } } -async function mount(reload?: () => Promise) { +async function mount(reload?: () => Promise<{ client: OpencodeClient; api: OpenCodeClient }>) { const events = createEventStream() const calls = createFetch(undefined, events) const seen: V2Event[] = [] @@ -60,7 +61,7 @@ async function mount(reload?: () => Promise) { const app = await testRender(() => ( - + { @@ -150,7 +151,8 @@ describe("useEvent", () => { test("reloads the host and reconnects the event stream", async () => { let calls = 0 const events = createEventStream() - const replacement = createClient(createFetch(undefined, events).fetch) + const replacementCalls = createFetch(undefined, events) + const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) } const { app, sdk, seen } = await mount(async () => { calls += 1 return replacement @@ -164,19 +166,21 @@ describe("useEvent", () => { await wait(() => seen.some((item) => item.type === "vcs.branch.updated" && item.data.branch === "reloaded")) expect(calls).toBe(1) - expect(sdk.client).toBe(replacement) + expect(sdk.client).toBe(replacement.client) + expect(sdk.api).toBe(replacement.api) } finally { app.renderer.destroy() } }) test("keeps the current event stream alive while the host reload is pending", async () => { - let complete!: (client: OpencodeClient) => void - const pending = new Promise((resolve) => { + let complete!: (client: { client: OpencodeClient; api: OpenCodeClient }) => void + const pending = new Promise<{ client: OpencodeClient; api: OpenCodeClient }>((resolve) => { complete = resolve }) const replacementEvents = createEventStream() - const replacement = createClient(createFetch(undefined, replacementEvents).fetch) + const replacementCalls = createFetch(undefined, replacementEvents) + const replacement = { client: createClient(replacementCalls.fetch), api: createApi(replacementCalls.fetch) } const { app, emit, sdk, seen } = await mount(() => pending) try { @@ -188,7 +192,8 @@ describe("useEvent", () => { expect(sdk.connection.status()).toBe("connected") complete(replacement) await reload - expect(sdk.client).toBe(replacement) + expect(sdk.client).toBe(replacement.client) + expect(sdk.api).toBe(replacement.api) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index d7f6cfb1a5..8cb704d626 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -1,3 +1,4 @@ +import { OpenCode } from "@opencode-ai/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import type { V2Event } from "@opencode-ai/sdk/v2" @@ -117,3 +118,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType Date: Mon, 29 Jun 2026 12:05:34 -0400 Subject: [PATCH 02/27] fix(core): spawn shell non-interactively without sourcing rc files ShellSelect.args() ran zsh/bash with -l and explicitly sourced .zshrc/.bashrc, loading user functions and aliases that can shadow builtins and return non-zero exit codes, breaking && chains. Match the old tool behavior: plain non-login non-interactive shell -c command with cwd passed via spawn options. --- packages/core/src/shell.ts | 2 +- packages/core/src/shell/select.ts | 31 ++----------------------- packages/core/test/shell.test.ts | 10 ++++---- packages/opencode/src/session/prompt.ts | 2 +- 4 files changed, 8 insertions(+), 37 deletions(-) diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 1340b345e4..8d4b254093 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -159,7 +159,7 @@ export const layer = Layer.effect( const cwd = input.cwd ?? location.directory const configShell = Config.latest(yield* config.entries(), "shell") const shell = ShellSelect.preferred(configShell) - const args = ShellSelect.args(shell, input.command, cwd) + const args = ShellSelect.args(shell, input.command) const file = path.join(outputDir, `${id}.out`) const env = { ...process.env, diff --git a/packages/core/src/shell/select.ts b/packages/core/src/shell/select.ts index 6bef02c203..110697421d 100644 --- a/packages/core/src/shell/select.ts +++ b/packages/core/src/shell/select.ts @@ -163,37 +163,10 @@ function info(file: string): Item { } } -export function args(file: string, command: string, cwd: string) { +export function args(file: string, command: string) { const n = name(file) if (n === "nu" || n === "fish") return ["-c", command] - if (n === "zsh") { - return [ - "-l", - "-c", - ` - [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true - [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true - cd -- "$1" - eval ${JSON.stringify(command)} - `, - "opencode", - cwd, - ] - } - if (n === "bash") { - return [ - "-l", - "-c", - ` - shopt -s expand_aliases - [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true - cd -- "$1" - eval ${JSON.stringify(command)} - `, - "opencode", - cwd, - ] - } + if (n === "zsh" || n === "bash") return ["-c", command] if (n === "cmd") return ["/c", command] if (ps(file)) return ["-NoProfile", "-Command", command] return ["-c", command] diff --git a/packages/core/test/shell.test.ts b/packages/core/test/shell.test.ts index d38c160d01..a3fd4f2b6b 100644 --- a/packages/core/test/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -55,12 +55,10 @@ describe("shell", () => { }) test("builds command args per shell family", () => { - expect(ShellSelect.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) - expect(ShellSelect.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) - const zsh = ShellSelect.args("/bin/zsh", "echo hi", "/tmp") - expect(zsh[0]).toBe("-l") - expect(zsh[1]).toBe("-c") - expect(zsh.at(-1)).toBe("/tmp") + expect(ShellSelect.args("/bin/sh", "echo hi")).toEqual(["-c", "echo hi"]) + expect(ShellSelect.args("/usr/bin/fish", "echo hi")).toEqual(["-c", "echo hi"]) + expect(ShellSelect.args("/bin/zsh", "echo hi")).toEqual(["-c", "echo hi"]) + expect(ShellSelect.args("/bin/bash", "echo hi")).toEqual(["-c", "echo hi"]) }) if (process.platform === "win32") { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8a0c713a49..98d58630ba 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -521,7 +521,7 @@ export const layer = Layer.effect( const cfg = yield* config.get() const sh = ShellSelect.preferred(cfg.shell) - const args = ShellSelect.args(sh, input.command, cwd) + const args = ShellSelect.args(sh, input.command) let output = "" let aborted = false From 935ac2db914f028efdfa05a1de61e271f8d28c15 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 29 Jun 2026 14:10:11 -0400 Subject: [PATCH 03/27] feat(client): expose v2 project APIs (#34456) --- packages/client/src/contract.ts | 1 + .../client/src/generated-effect/client.ts | 410 +++++++++--------- packages/client/src/generated/client.ts | 30 ++ packages/client/src/generated/types.ts | 17 + .../client/test/contract-identity.test.ts | 5 + packages/client/test/promise.test.ts | 29 ++ packages/core/src/location-services.ts | 2 + packages/core/src/project.ts | 10 +- packages/core/src/project/directories.ts | 19 +- packages/core/src/project/schema.ts | 12 + packages/core/test/location-layer.test.ts | 4 +- .../core/test/project-directories.test.ts | 8 + packages/core/test/shared-schema.test.ts | 4 + packages/core/test/tool-shell.test.ts | 34 +- packages/protocol/src/api.ts | 2 + packages/protocol/src/groups/project.ts | 42 ++ packages/schema/src/project.ts | 18 +- packages/schema/test/contract-hygiene.test.ts | 5 +- packages/server/src/handlers.ts | 2 + packages/server/src/handlers/project.ts | 17 + .../tui/src/component/dialog-move-session.tsx | 27 +- packages/tui/src/context/project.tsx | 13 +- packages/tui/test/fixture/tui-sdk.ts | 4 +- 23 files changed, 472 insertions(+), 243 deletions(-) create mode 100644 packages/protocol/src/groups/project.ts create mode 100644 packages/server/src/handlers/project.ts diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index f117e058bb..61dda7c610 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -35,6 +35,7 @@ export const groupNames = { "server.pty": "ptys", "server.question": "questions", "server.reference": "references", + "server.project": "project", "server.projectCopy": "projectCopies", } as const diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index bd1ab7845c..ed041ed50f 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -410,36 +410,57 @@ const Endpoint9_1 = (raw: RawClient["server.credential"]) => (input: Endpoint9_1 const adaptGroup9 = (raw: RawClient["server.credential"]) => ({ update: Endpoint9_0(raw), remove: Endpoint9_1(raw) }) -type Endpoint10_0Request = Parameters[0] +type Endpoint10_0Request = Parameters[0] type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] } -const Endpoint10_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_0Input) => +const Endpoint10_0 = (raw: RawClient["server.project"]) => (input?: Endpoint10_0Input) => + raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint10_1Request = Parameters[0] +type Endpoint10_1Input = { + readonly projectID: Endpoint10_1Request["params"]["projectID"] + readonly location?: Endpoint10_1Request["query"]["location"] +} +const Endpoint10_1 = (raw: RawClient["server.project"]) => (input: Endpoint10_1Input) => + raw["project.directories"]({ + params: { projectID: input["projectID"] }, + query: { location: input["location"] }, + }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup10 = (raw: RawClient["server.project"]) => ({ + current: Endpoint10_0(raw), + directories: Endpoint10_1(raw), +}) + +type Endpoint11_0Request = Parameters[0] +type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } +const Endpoint11_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint11_0Input) => raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_1Request = Parameters[0] -type Endpoint10_1Input = { readonly projectID?: Endpoint10_1Request["query"]["projectID"] } -const Endpoint10_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_1Input) => +type Endpoint11_1Request = Parameters[0] +type Endpoint11_1Input = { readonly projectID?: Endpoint11_1Request["query"]["projectID"] } +const Endpoint11_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint11_1Input) => raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint10_2Request = Parameters[0] -type Endpoint10_2Input = { readonly id: Endpoint10_2Request["params"]["id"] } -const Endpoint10_2 = (raw: RawClient["server.permission"]) => (input: Endpoint10_2Input) => +type Endpoint11_2Request = Parameters[0] +type Endpoint11_2Input = { readonly id: Endpoint11_2Request["params"]["id"] } +const Endpoint11_2 = (raw: RawClient["server.permission"]) => (input: Endpoint11_2Input) => raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_3Request = Parameters[0] -type Endpoint10_3Input = { - readonly sessionID: Endpoint10_3Request["params"]["sessionID"] - readonly id?: Endpoint10_3Request["payload"]["id"] - readonly action: Endpoint10_3Request["payload"]["action"] - readonly resources: Endpoint10_3Request["payload"]["resources"] - readonly save?: Endpoint10_3Request["payload"]["save"] - readonly metadata?: Endpoint10_3Request["payload"]["metadata"] - readonly source?: Endpoint10_3Request["payload"]["source"] - readonly agent?: Endpoint10_3Request["payload"]["agent"] +type Endpoint11_3Request = Parameters[0] +type Endpoint11_3Input = { + readonly sessionID: Endpoint11_3Request["params"]["sessionID"] + readonly id?: Endpoint11_3Request["payload"]["id"] + readonly action: Endpoint11_3Request["payload"]["action"] + readonly resources: Endpoint11_3Request["payload"]["resources"] + readonly save?: Endpoint11_3Request["payload"]["save"] + readonly metadata?: Endpoint11_3Request["payload"]["metadata"] + readonly source?: Endpoint11_3Request["payload"]["source"] + readonly agent?: Endpoint11_3Request["payload"]["agent"] } -const Endpoint10_3 = (raw: RawClient["server.permission"]) => (input: Endpoint10_3Input) => +const Endpoint11_3 = (raw: RawClient["server.permission"]) => (input: Endpoint11_3Input) => raw["session.permission.create"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -456,87 +477,87 @@ const Endpoint10_3 = (raw: RawClient["server.permission"]) => (input: Endpoint10 Effect.map((value) => value.data), ) -type Endpoint10_4Request = Parameters[0] -type Endpoint10_4Input = { readonly sessionID: Endpoint10_4Request["params"]["sessionID"] } -const Endpoint10_4 = (raw: RawClient["server.permission"]) => (input: Endpoint10_4Input) => +type Endpoint11_4Request = Parameters[0] +type Endpoint11_4Input = { readonly sessionID: Endpoint11_4Request["params"]["sessionID"] } +const Endpoint11_4 = (raw: RawClient["server.permission"]) => (input: Endpoint11_4Input) => raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint10_5Request = Parameters[0] -type Endpoint10_5Input = { - readonly sessionID: Endpoint10_5Request["params"]["sessionID"] - readonly requestID: Endpoint10_5Request["params"]["requestID"] +type Endpoint11_5Request = Parameters[0] +type Endpoint11_5Input = { + readonly sessionID: Endpoint11_5Request["params"]["sessionID"] + readonly requestID: Endpoint11_5Request["params"]["requestID"] } -const Endpoint10_5 = (raw: RawClient["server.permission"]) => (input: Endpoint10_5Input) => +const Endpoint11_5 = (raw: RawClient["server.permission"]) => (input: Endpoint11_5Input) => raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint10_6Request = Parameters[0] -type Endpoint10_6Input = { - readonly sessionID: Endpoint10_6Request["params"]["sessionID"] - readonly requestID: Endpoint10_6Request["params"]["requestID"] - readonly reply: Endpoint10_6Request["payload"]["reply"] - readonly message?: Endpoint10_6Request["payload"]["message"] +type Endpoint11_6Request = Parameters[0] +type Endpoint11_6Input = { + readonly sessionID: Endpoint11_6Request["params"]["sessionID"] + readonly requestID: Endpoint11_6Request["params"]["requestID"] + readonly reply: Endpoint11_6Request["payload"]["reply"] + readonly message?: Endpoint11_6Request["payload"]["message"] } -const Endpoint10_6 = (raw: RawClient["server.permission"]) => (input: Endpoint10_6Input) => +const Endpoint11_6 = (raw: RawClient["server.permission"]) => (input: Endpoint11_6Input) => raw["session.permission.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { reply: input["reply"], message: input["message"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup10 = (raw: RawClient["server.permission"]) => ({ - listRequests: Endpoint10_0(raw), - listSaved: Endpoint10_1(raw), - removeSaved: Endpoint10_2(raw), - create: Endpoint10_3(raw), - list: Endpoint10_4(raw), - get: Endpoint10_5(raw), - reply: Endpoint10_6(raw), +const adaptGroup11 = (raw: RawClient["server.permission"]) => ({ + listRequests: Endpoint11_0(raw), + listSaved: Endpoint11_1(raw), + removeSaved: Endpoint11_2(raw), + create: Endpoint11_3(raw), + list: Endpoint11_4(raw), + get: Endpoint11_5(raw), + reply: Endpoint11_6(raw), }) -type Endpoint11_0Request = Parameters[0] -type Endpoint11_0Input = { - readonly location?: Endpoint11_0Request["query"]["location"] - readonly path?: Endpoint11_0Request["query"]["path"] +type Endpoint12_0Request = Parameters[0] +type Endpoint12_0Input = { + readonly location?: Endpoint12_0Request["query"]["location"] + readonly path?: Endpoint12_0Request["query"]["path"] } -const Endpoint11_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint11_0Input) => +const Endpoint12_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint12_0Input) => raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint11_1Request = Parameters[0] -type Endpoint11_1Input = { - readonly location?: Endpoint11_1Request["query"]["location"] - readonly query: Endpoint11_1Request["query"]["query"] - readonly type?: Endpoint11_1Request["query"]["type"] - readonly limit?: Endpoint11_1Request["query"]["limit"] +type Endpoint12_1Request = Parameters[0] +type Endpoint12_1Input = { + readonly location?: Endpoint12_1Request["query"]["location"] + readonly query: Endpoint12_1Request["query"]["query"] + readonly type?: Endpoint12_1Request["query"]["type"] + readonly limit?: Endpoint12_1Request["query"]["limit"] } -const Endpoint11_1 = (raw: RawClient["server.fs"]) => (input: Endpoint11_1Input) => +const Endpoint12_1 = (raw: RawClient["server.fs"]) => (input: Endpoint12_1Input) => raw["fs.find"]({ query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup11 = (raw: RawClient["server.fs"]) => ({ list: Endpoint11_0(raw), find: Endpoint11_1(raw) }) +const adaptGroup12 = (raw: RawClient["server.fs"]) => ({ list: Endpoint12_0(raw), find: Endpoint12_1(raw) }) -type Endpoint12_0Request = Parameters[0] -type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } -const Endpoint12_0 = (raw: RawClient["server.command"]) => (input?: Endpoint12_0Input) => +type Endpoint13_0Request = Parameters[0] +type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } +const Endpoint13_0 = (raw: RawClient["server.command"]) => (input?: Endpoint13_0Input) => raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup12 = (raw: RawClient["server.command"]) => ({ list: Endpoint12_0(raw) }) +const adaptGroup13 = (raw: RawClient["server.command"]) => ({ list: Endpoint13_0(raw) }) -type Endpoint13_0Request = Parameters[0] -type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } -const Endpoint13_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint13_0Input) => +type Endpoint14_0Request = Parameters[0] +type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } +const Endpoint14_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint14_0Input) => raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup13 = (raw: RawClient["server.skill"]) => ({ list: Endpoint13_0(raw) }) +const adaptGroup14 = (raw: RawClient["server.skill"]) => ({ list: Endpoint14_0(raw) }) -const Endpoint14_0 = (raw: RawClient["server.event"]) => () => +const Endpoint15_0 = (raw: RawClient["server.event"]) => () => Stream.unwrap( raw["event.subscribe"]({}).pipe( Effect.mapError(mapClientError), @@ -544,23 +565,23 @@ const Endpoint14_0 = (raw: RawClient["server.event"]) => () => ), ) -const adaptGroup14 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint14_0(raw) }) +const adaptGroup15 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint15_0(raw) }) -type Endpoint15_0Request = Parameters[0] -type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } -const Endpoint15_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_0Input) => +type Endpoint16_0Request = Parameters[0] +type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } +const Endpoint16_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint16_0Input) => raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_1Request = Parameters[0] -type Endpoint15_1Input = { - readonly location?: Endpoint15_1Request["query"]["location"] - readonly command?: Endpoint15_1Request["payload"]["command"] - readonly args?: Endpoint15_1Request["payload"]["args"] - readonly cwd?: Endpoint15_1Request["payload"]["cwd"] - readonly title?: Endpoint15_1Request["payload"]["title"] - readonly env?: Endpoint15_1Request["payload"]["env"] +type Endpoint16_1Request = Parameters[0] +type Endpoint16_1Input = { + readonly location?: Endpoint16_1Request["query"]["location"] + readonly command?: Endpoint16_1Request["payload"]["command"] + readonly args?: Endpoint16_1Request["payload"]["args"] + readonly cwd?: Endpoint16_1Request["payload"]["cwd"] + readonly title?: Endpoint16_1Request["payload"]["title"] + readonly env?: Endpoint16_1Request["payload"]["env"] } -const Endpoint15_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_1Input) => +const Endpoint16_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint16_1Input) => raw["pty.create"]({ query: { location: input?.["location"] }, payload: { @@ -572,201 +593,201 @@ const Endpoint15_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_1Inpu }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_2Request = Parameters[0] -type Endpoint15_2Input = { - readonly ptyID: Endpoint15_2Request["params"]["ptyID"] - readonly location?: Endpoint15_2Request["query"]["location"] +type Endpoint16_2Request = Parameters[0] +type Endpoint16_2Input = { + readonly ptyID: Endpoint16_2Request["params"]["ptyID"] + readonly location?: Endpoint16_2Request["query"]["location"] } -const Endpoint15_2 = (raw: RawClient["server.pty"]) => (input: Endpoint15_2Input) => +const Endpoint16_2 = (raw: RawClient["server.pty"]) => (input: Endpoint16_2Input) => raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint15_3Request = Parameters[0] -type Endpoint15_3Input = { - readonly ptyID: Endpoint15_3Request["params"]["ptyID"] - readonly location?: Endpoint15_3Request["query"]["location"] - readonly title?: Endpoint15_3Request["payload"]["title"] - readonly size?: Endpoint15_3Request["payload"]["size"] +type Endpoint16_3Request = Parameters[0] +type Endpoint16_3Input = { + readonly ptyID: Endpoint16_3Request["params"]["ptyID"] + readonly location?: Endpoint16_3Request["query"]["location"] + readonly title?: Endpoint16_3Request["payload"]["title"] + readonly size?: Endpoint16_3Request["payload"]["size"] } -const Endpoint15_3 = (raw: RawClient["server.pty"]) => (input: Endpoint15_3Input) => +const Endpoint16_3 = (raw: RawClient["server.pty"]) => (input: Endpoint16_3Input) => raw["pty.update"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] }, payload: { title: input["title"], size: input["size"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint15_4Request = Parameters[0] -type Endpoint15_4Input = { - readonly ptyID: Endpoint15_4Request["params"]["ptyID"] - readonly location?: Endpoint15_4Request["query"]["location"] +type Endpoint16_4Request = Parameters[0] +type Endpoint16_4Input = { + readonly ptyID: Endpoint16_4Request["params"]["ptyID"] + readonly location?: Endpoint16_4Request["query"]["location"] } -const Endpoint15_4 = (raw: RawClient["server.pty"]) => (input: Endpoint15_4Input) => +const Endpoint16_4 = (raw: RawClient["server.pty"]) => (input: Endpoint16_4Input) => raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup15 = (raw: RawClient["server.pty"]) => ({ - list: Endpoint15_0(raw), - create: Endpoint15_1(raw), - get: Endpoint15_2(raw), - update: Endpoint15_3(raw), - remove: Endpoint15_4(raw), +const adaptGroup16 = (raw: RawClient["server.pty"]) => ({ + list: Endpoint16_0(raw), + create: Endpoint16_1(raw), + get: Endpoint16_2(raw), + update: Endpoint16_3(raw), + remove: Endpoint16_4(raw), }) -type Endpoint16_0Request = Parameters[0] -type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } -const Endpoint16_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint16_0Input) => +type Endpoint17_0Request = Parameters[0] +type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } +const Endpoint17_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint17_0Input) => raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_1Request = Parameters[0] -type Endpoint16_1Input = { - readonly location?: Endpoint16_1Request["query"]["location"] - readonly command: Endpoint16_1Request["payload"]["command"] - readonly cwd?: Endpoint16_1Request["payload"]["cwd"] - readonly timeout?: Endpoint16_1Request["payload"]["timeout"] - readonly metadata?: Endpoint16_1Request["payload"]["metadata"] +type Endpoint17_1Request = Parameters[0] +type Endpoint17_1Input = { + readonly location?: Endpoint17_1Request["query"]["location"] + readonly command: Endpoint17_1Request["payload"]["command"] + readonly cwd?: Endpoint17_1Request["payload"]["cwd"] + readonly timeout?: Endpoint17_1Request["payload"]["timeout"] + readonly metadata?: Endpoint17_1Request["payload"]["metadata"] } -const Endpoint16_1 = (raw: RawClient["server.shell"]) => (input: Endpoint16_1Input) => +const Endpoint17_1 = (raw: RawClient["server.shell"]) => (input: Endpoint17_1Input) => raw["shell.create"]({ query: { location: input["location"] }, payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_2Request = Parameters[0] -type Endpoint16_2Input = { - readonly id: Endpoint16_2Request["params"]["id"] - readonly location?: Endpoint16_2Request["query"]["location"] +type Endpoint17_2Request = Parameters[0] +type Endpoint17_2Input = { + readonly id: Endpoint17_2Request["params"]["id"] + readonly location?: Endpoint17_2Request["query"]["location"] } -const Endpoint16_2 = (raw: RawClient["server.shell"]) => (input: Endpoint16_2Input) => +const Endpoint17_2 = (raw: RawClient["server.shell"]) => (input: Endpoint17_2Input) => raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint16_3Request = Parameters[0] -type Endpoint16_3Input = { - readonly id: Endpoint16_3Request["params"]["id"] - readonly location?: Endpoint16_3Request["query"]["location"] - readonly cursor?: Endpoint16_3Request["query"]["cursor"] - readonly limit?: Endpoint16_3Request["query"]["limit"] +type Endpoint17_3Request = Parameters[0] +type Endpoint17_3Input = { + readonly id: Endpoint17_3Request["params"]["id"] + readonly location?: Endpoint17_3Request["query"]["location"] + readonly cursor?: Endpoint17_3Request["query"]["cursor"] + readonly limit?: Endpoint17_3Request["query"]["limit"] } -const Endpoint16_3 = (raw: RawClient["server.shell"]) => (input: Endpoint16_3Input) => +const Endpoint17_3 = (raw: RawClient["server.shell"]) => (input: Endpoint17_3Input) => raw["shell.output"]({ params: { id: input["id"] }, query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_4Request = Parameters[0] -type Endpoint16_4Input = { - readonly id: Endpoint16_4Request["params"]["id"] - readonly location?: Endpoint16_4Request["query"]["location"] +type Endpoint17_4Request = Parameters[0] +type Endpoint17_4Input = { + readonly id: Endpoint17_4Request["params"]["id"] + readonly location?: Endpoint17_4Request["query"]["location"] } -const Endpoint16_4 = (raw: RawClient["server.shell"]) => (input: Endpoint16_4Input) => +const Endpoint17_4 = (raw: RawClient["server.shell"]) => (input: Endpoint17_4Input) => raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup16 = (raw: RawClient["server.shell"]) => ({ - list: Endpoint16_0(raw), - create: Endpoint16_1(raw), - get: Endpoint16_2(raw), - output: Endpoint16_3(raw), - remove: Endpoint16_4(raw), +const adaptGroup17 = (raw: RawClient["server.shell"]) => ({ + list: Endpoint17_0(raw), + create: Endpoint17_1(raw), + get: Endpoint17_2(raw), + output: Endpoint17_3(raw), + remove: Endpoint17_4(raw), }) -type Endpoint17_0Request = Parameters[0] -type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } -const Endpoint17_0 = (raw: RawClient["server.question"]) => (input?: Endpoint17_0Input) => +type Endpoint18_0Request = Parameters[0] +type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] } +const Endpoint18_0 = (raw: RawClient["server.question"]) => (input?: Endpoint18_0Input) => raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint17_1Request = Parameters[0] -type Endpoint17_1Input = { readonly sessionID: Endpoint17_1Request["params"]["sessionID"] } -const Endpoint17_1 = (raw: RawClient["server.question"]) => (input: Endpoint17_1Input) => +type Endpoint18_1Request = Parameters[0] +type Endpoint18_1Input = { readonly sessionID: Endpoint18_1Request["params"]["sessionID"] } +const Endpoint18_1 = (raw: RawClient["server.question"]) => (input: Endpoint18_1Input) => raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint17_2Request = Parameters[0] -type Endpoint17_2Input = { - readonly sessionID: Endpoint17_2Request["params"]["sessionID"] - readonly requestID: Endpoint17_2Request["params"]["requestID"] - readonly answers: Endpoint17_2Request["payload"]["answers"] +type Endpoint18_2Request = Parameters[0] +type Endpoint18_2Input = { + readonly sessionID: Endpoint18_2Request["params"]["sessionID"] + readonly requestID: Endpoint18_2Request["params"]["requestID"] + readonly answers: Endpoint18_2Request["payload"]["answers"] } -const Endpoint17_2 = (raw: RawClient["server.question"]) => (input: Endpoint17_2Input) => +const Endpoint18_2 = (raw: RawClient["server.question"]) => (input: Endpoint18_2Input) => raw["session.question.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { answers: input["answers"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint17_3Request = Parameters[0] -type Endpoint17_3Input = { - readonly sessionID: Endpoint17_3Request["params"]["sessionID"] - readonly requestID: Endpoint17_3Request["params"]["requestID"] +type Endpoint18_3Request = Parameters[0] +type Endpoint18_3Input = { + readonly sessionID: Endpoint18_3Request["params"]["sessionID"] + readonly requestID: Endpoint18_3Request["params"]["requestID"] } -const Endpoint17_3 = (raw: RawClient["server.question"]) => (input: Endpoint17_3Input) => +const Endpoint18_3 = (raw: RawClient["server.question"]) => (input: Endpoint18_3Input) => raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup17 = (raw: RawClient["server.question"]) => ({ - listRequests: Endpoint17_0(raw), - list: Endpoint17_1(raw), - reply: Endpoint17_2(raw), - reject: Endpoint17_3(raw), +const adaptGroup18 = (raw: RawClient["server.question"]) => ({ + listRequests: Endpoint18_0(raw), + list: Endpoint18_1(raw), + reply: Endpoint18_2(raw), + reject: Endpoint18_3(raw), }) -type Endpoint18_0Request = Parameters[0] -type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] } -const Endpoint18_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint18_0Input) => +type Endpoint19_0Request = Parameters[0] +type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] } +const Endpoint19_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint19_0Input) => raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup18 = (raw: RawClient["server.reference"]) => ({ list: Endpoint18_0(raw) }) +const adaptGroup19 = (raw: RawClient["server.reference"]) => ({ list: Endpoint19_0(raw) }) -type Endpoint19_0Request = Parameters[0] -type Endpoint19_0Input = { - readonly projectID: Endpoint19_0Request["params"]["projectID"] - readonly location?: Endpoint19_0Request["query"]["location"] - readonly strategy: Endpoint19_0Request["payload"]["strategy"] - readonly directory: Endpoint19_0Request["payload"]["directory"] - readonly name?: Endpoint19_0Request["payload"]["name"] +type Endpoint20_0Request = Parameters[0] +type Endpoint20_0Input = { + readonly projectID: Endpoint20_0Request["params"]["projectID"] + readonly location?: Endpoint20_0Request["query"]["location"] + readonly strategy: Endpoint20_0Request["payload"]["strategy"] + readonly directory: Endpoint20_0Request["payload"]["directory"] + readonly name?: Endpoint20_0Request["payload"]["name"] } -const Endpoint19_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_0Input) => +const Endpoint20_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_0Input) => raw["projectCopy.create"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint19_1Request = Parameters[0] -type Endpoint19_1Input = { - readonly projectID: Endpoint19_1Request["params"]["projectID"] - readonly location?: Endpoint19_1Request["query"]["location"] - readonly directory: Endpoint19_1Request["payload"]["directory"] - readonly force: Endpoint19_1Request["payload"]["force"] +type Endpoint20_1Request = Parameters[0] +type Endpoint20_1Input = { + readonly projectID: Endpoint20_1Request["params"]["projectID"] + readonly location?: Endpoint20_1Request["query"]["location"] + readonly directory: Endpoint20_1Request["payload"]["directory"] + readonly force: Endpoint20_1Request["payload"]["force"] } -const Endpoint19_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_1Input) => +const Endpoint20_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_1Input) => raw["projectCopy.remove"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { directory: input["directory"], force: input["force"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint19_2Request = Parameters[0] -type Endpoint19_2Input = { - readonly projectID: Endpoint19_2Request["params"]["projectID"] - readonly location?: Endpoint19_2Request["query"]["location"] +type Endpoint20_2Request = Parameters[0] +type Endpoint20_2Input = { + readonly projectID: Endpoint20_2Request["params"]["projectID"] + readonly location?: Endpoint20_2Request["query"]["location"] } -const Endpoint19_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_2Input) => +const Endpoint20_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_2Input) => raw["projectCopy.refresh"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup19 = (raw: RawClient["server.projectCopy"]) => ({ - create: Endpoint19_0(raw), - remove: Endpoint19_1(raw), - refresh: Endpoint19_2(raw), +const adaptGroup20 = (raw: RawClient["server.projectCopy"]) => ({ + create: Endpoint20_0(raw), + remove: Endpoint20_1(raw), + refresh: Endpoint20_2(raw), }) const adaptClient = (raw: RawClient) => ({ @@ -780,16 +801,17 @@ const adaptClient = (raw: RawClient) => ({ providers: adaptGroup7(raw["server.provider"]), integrations: adaptGroup8(raw["server.integration"]), credentials: adaptGroup9(raw["server.credential"]), - permissions: adaptGroup10(raw["server.permission"]), - files: adaptGroup11(raw["server.fs"]), - commands: adaptGroup12(raw["server.command"]), - skills: adaptGroup13(raw["server.skill"]), - events: adaptGroup14(raw["server.event"]), - ptys: adaptGroup15(raw["server.pty"]), - "server.shell": adaptGroup16(raw["server.shell"]), - questions: adaptGroup17(raw["server.question"]), - references: adaptGroup18(raw["server.reference"]), - projectCopies: adaptGroup19(raw["server.projectCopy"]), + project: adaptGroup10(raw["server.project"]), + permissions: adaptGroup11(raw["server.permission"]), + files: adaptGroup12(raw["server.fs"]), + commands: adaptGroup13(raw["server.command"]), + skills: adaptGroup14(raw["server.skill"]), + events: adaptGroup15(raw["server.event"]), + ptys: adaptGroup16(raw["server.pty"]), + "server.shell": adaptGroup17(raw["server.shell"]), + questions: adaptGroup18(raw["server.question"]), + references: adaptGroup19(raw["server.reference"]), + projectCopies: adaptGroup20(raw["server.projectCopy"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 56802f8c6c..e0ceb3bf2a 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -67,6 +67,10 @@ import type { CredentialsUpdateOutput, CredentialsRemoveInput, CredentialsRemoveOutput, + ProjectCurrentInput, + ProjectCurrentOutput, + ProjectDirectoriesInput, + ProjectDirectoriesOutput, PermissionsListRequestsInput, PermissionsListRequestsOutput, PermissionsListSavedInput, @@ -704,6 +708,32 @@ export function make(options: ClientOptions) { requestOptions, ), }, + project: { + current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/project/current`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + directories: (input: ProjectDirectoriesInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/project/${encodeURIComponent(input.projectID)}/directories`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, permissions: { listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) => request( diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index cffc6fac5c..50aa222117 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2339,6 +2339,23 @@ export type CredentialsRemoveInput = { export type CredentialsRemoveOutput = void +export type ProjectCurrentInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ProjectCurrentOutput = { readonly id: string; readonly directory: string } + +export type ProjectDirectoriesInput = { + readonly projectID: { readonly projectID: string }["projectID"] + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ProjectDirectoriesOutput = ReadonlyArray<{ readonly directory: string; readonly strategy?: string }> + export type PermissionsListRequestsInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index 64a2e958ce..dc75a8e650 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -3,6 +3,7 @@ import { Schema } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Location as CoreLocation } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" +import { ProjectV2 } from "@opencode-ai/core/project" import { SessionV2 } from "@opencode-ai/core/session" import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input" import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message" @@ -26,10 +27,14 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => expect(CoreLocation.Ref).toBe(Location.Ref) expect(ModelV2.Ref).toBe(Model.Ref) expect(SessionV2.Info).toBe(Session.Info) + expect(ProjectV2.Current).toBe(Project.Current) + expect(ProjectV2.Directory).toBe(Project.Directory) + expect(ProjectV2.Directories).toBe(Project.Directories) expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted) expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) expect(CorePrompt).toBe(Prompt) 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)) expect(Session.ID.create()).toStartWith("ses_") expect(Project.ID.global).toBe("global") diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index dfc88909ce..dba04e6706 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -15,12 +15,14 @@ test("exposes every standard HTTP API group", () => { "providers", "integrations", "credentials", + "project", "permissions", "files", "commands", "skills", "events", "ptys", + "server.shell", "questions", "references", "projectCopies", @@ -37,6 +39,33 @@ test("exposes every standard HTTP API group", () => { ]) expect(Object.keys(client.files)).toEqual(["list", "find"]) expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"]) + expect(Object.keys(client.project)).toEqual(["current", "directories"]) +}) + +test("project methods use the public HTTP contract", async () => { + const requests: string[] = [] + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + requests.push(url) + if (url.includes("/directories")) return Response.json([]) + return Response.json({ id: "proj_test", directory: "/tmp/project" }) + }, + }) + + const current = await client.project.current({ location: { workspace: "wrk_test" } }) + const directories = await client.project.directories({ + projectID: current.id, + location: { directory: current.directory }, + }) + + expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" }) + expect(directories).toEqual([]) + expect(requests).toEqual([ + "http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test", + "http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject", + ]) }) test("sessions.get returns the wire projection", async () => { diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index a0ae0228c7..cdc10ff3c8 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -20,6 +20,7 @@ import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" import { PluginInternal } from "./plugin/internal" import { Policy } from "./policy" +import { Project } from "./project" import { ProjectCopy } from "./project/copy" import { Pty } from "./pty" import { QuestionV2 } from "./question" @@ -43,6 +44,7 @@ import { ToolOutputStore } from "./tool-output-store" export { LocationServiceMap } from "./location-service-map" export const locationServices = LayerNode.group([ + Project.node, Location.node, Policy.node, Config.node, diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 5804ff93ff..0ed2efa866 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -17,14 +17,20 @@ export type ID = ProjectSchema.ID export const Vcs = ProjectSchema.Vcs export type Vcs = ProjectSchema.Vcs +export const Current = ProjectSchema.Current +export type Current = ProjectSchema.Current + +export const Directory = ProjectSchema.Directory +export type Directory = ProjectSchema.Directory + export class Info extends Schema.Class("Project.Info")({ id: ID, }) {} -export const DirectoriesInput = ProjectDirectories.ListInput +export const DirectoriesInput = ProjectSchema.DirectoriesInput export type DirectoriesInput = typeof DirectoriesInput.Type -export const Directories = ProjectDirectories.ListOutput +export const Directories = ProjectSchema.Directories export type Directories = typeof Directories.Type export interface Resolved { diff --git a/packages/core/src/project/directories.ts b/packages/core/src/project/directories.ts index 6f7e36c35e..7fffb900bc 100644 --- a/packages/core/src/project/directories.ts +++ b/packages/core/src/project/directories.ts @@ -4,15 +4,13 @@ import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { makeGlobalNode } from "../effect/app-node" -import { AbsolutePath, optional } from "../schema" +import { AbsolutePath } from "../schema" import { ProjectSchema } from "./schema" import { ProjectDirectoryTable } from "./sql" import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import type { Project } from "../project" -export interface Directory { - readonly directory: AbsolutePath - readonly strategy?: string -} +export type Directory = Project.Directory export const CreateInput = Schema.Struct({ projectID: ProjectSchema.ID, @@ -31,17 +29,10 @@ export type RemoveInput = typeof RemoveInput.Type type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase export type Transaction = Parameters[0]>[0] -export const ListInput = Schema.Struct({ - projectID: ProjectSchema.ID, -}).annotate({ identifier: "Project.DirectoriesInput" }) +export const ListInput = ProjectSchema.DirectoriesInput export type ListInput = typeof ListInput.Type -export const ListOutput = Schema.Array( - Schema.Struct({ - directory: AbsolutePath, - strategy: optional(Schema.String), - }), -).annotate({ identifier: "Project.Directories" }) +export const ListOutput = ProjectSchema.Directories export type ListOutput = typeof ListOutput.Type export interface Interface { diff --git a/packages/core/src/project/schema.ts b/packages/core/src/project/schema.ts index eed359abad..5866551e4e 100644 --- a/packages/core/src/project/schema.ts +++ b/packages/core/src/project/schema.ts @@ -7,6 +7,18 @@ import { AbsolutePath } from "../schema" export const ID = Project.ID export type ID = typeof ID.Type +export const Current = Project.Current +export type Current = typeof Current.Type + +export const Directory = Project.Directory +export type Directory = typeof Directory.Type + +export const DirectoriesInput = Project.DirectoriesInput +export type DirectoriesInput = typeof DirectoriesInput.Type + +export const Directories = Project.Directories +export type Directories = typeof Directories.Type + export const Vcs = Schema.Union([ Schema.Struct({ type: Schema.Literal("git"), diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 5c4f21ef1f..ad6257a492 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -121,12 +121,12 @@ describe("LocationServiceMap", () => { expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([ "application_context", "apply_patch", - "bash", "edit", "glob", "grep", "question", "read", + "shell", "skill", "todowrite", "webfetch", @@ -138,12 +138,12 @@ describe("LocationServiceMap", () => { expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([ "application_context", "apply_patch", - "bash", "edit", "glob", "grep", "question", "read", + "shell", "skill", "todowrite", "webfetch", diff --git a/packages/core/test/project-directories.test.ts b/packages/core/test/project-directories.test.ts index d91683cf42..d070376b68 100644 --- a/packages/core/test/project-directories.test.ts +++ b/packages/core/test/project-directories.test.ts @@ -44,6 +44,14 @@ describe("ProjectDirectories", () => { }), ) + it.effect("returns an empty list for missing projects", () => + Effect.gen(function* () { + const service = yield* ProjectDirectories.Service + + expect(yield* service.list(Project.ID.make("missing-project"))).toEqual([]) + }), + ) + it.effect("replaces the strategy when requested", () => Effect.gen(function* () { yield* setup() diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 959024cd4a..e7556a8b7c 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -134,6 +134,10 @@ test("Core reuses the canonical shared schemas", async () => { [corePty.Info, Pty.Info], [corePty.Event, Pty.Event], [coreProject.ID, Project.ID], + [coreProject.Current, Project.Current], + [coreProject.Directory, Project.Directory], + [coreProject.DirectoriesInput, Project.DirectoriesInput], + [coreProject.Directories, Project.Directories], [coreReference.LocalSource, Reference.LocalSource], [coreReference.GitSource, Reference.GitSource], [coreReference.Source, Reference.Source], diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index a97d31dd11..8f760aa123 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -88,6 +88,18 @@ const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({ call: { type: "tool-call" as const, id, name: "shell", input }, }) +const isWindows = process.platform === "win32" +const cwdCommand = isWindows ? "(Get-Location).Path; Start-Sleep -Milliseconds 100" : "pwd" +const helloCommand = isWindows ? "[Console]::Out.Write('hello'); Start-Sleep -Milliseconds 100" : "printf hello" +const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60" +const bodyExitCommand = isWindows + ? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7" + : "printf body && exit 7" +const overflowCommand = (bytes: number) => + isWindows + ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` + : `head -c ${bytes} /dev/zero | tr '\\0' 'x'` + const it = testEffect(Layer.empty) describe("ShellTool", () => { @@ -103,14 +115,14 @@ describe("ShellTool", () => { expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output") expect(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).toEqual([]) - const settled = yield* settleTool(registry, call({ command: "printf hello" })) + const settled = yield* settleTool(registry, call({ command: helloCommand })) expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false }) expect(settled.output?.content[0]).toEqual({ type: "text", text: "hello" }) expect(settled.output?.content[1]).toMatchObject({ type: "text", text: expect.stringContaining("Command exited with code 0."), }) - expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: ["printf hello"] }]) + expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: [helloCommand] }]) }), ) }, @@ -128,7 +140,9 @@ describe("ShellTool", () => { reset() return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe( Effect.andThen( - withTool(data.path, tmp.path, (registry) => settleTool(registry, call({ command: "pwd", workdir: "src" }))), + withTool(data.path, tmp.path, (registry) => + settleTool(registry, call({ command: cwdCommand, workdir: "src" })), + ), ), Effect.andThen((settled) => Effect.sync(() => @@ -163,7 +177,7 @@ describe("ShellTool", () => { return Effect.promise(() => fs.mkdir(workdir)).pipe( Effect.andThen( withTool(data.path, tmp.path, (registry) => - executeTool(registry, call({ command: "pwd", workdir: "src" })), + executeTool(registry, call({ command: cwdCommand, workdir: "src" })), ), ), Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))), @@ -182,7 +196,7 @@ describe("ShellTool", () => { ([data, active, outside]) => { reset() return withTool(data.path, active.path, (registry) => - executeTool(registry, call({ command: "pwd", workdir: outside.path })), + executeTool(registry, call({ command: cwdCommand, workdir: outside.path })), ).pipe( Effect.andThen( Effect.sync(() => { @@ -213,13 +227,13 @@ describe("ShellTool", () => { reset() denyAction = "external_directory" yield* withTool(data.path, active.path, (registry) => - executeTool(registry, call({ command: "pwd", workdir: outside.path })), + executeTool(registry, call({ command: cwdCommand, workdir: outside.path })), ) expect(assertions.map((item) => item.action)).toEqual(["external_directory"]) reset() denyAction = "shell" - yield* withTool(data.path, active.path, (registry) => executeTool(registry, call({ command: "pwd" }))) + yield* withTool(data.path, active.path, (registry) => executeTool(registry, call({ command: cwdCommand }))) expect(assertions.map((item) => item.action)).toEqual(["shell"]) }), ([data, active, outside]) => @@ -272,7 +286,7 @@ describe("ShellTool", () => { ([data, tmp]) => { reset() return withTool(data.path, tmp.path, (registry) => - settleTool(registry, call({ command: "printf body && exit 7" }, "call-nonzero")), + settleTool(registry, call({ command: bodyExitCommand }, "call-nonzero")), ).pipe( Effect.andThen((settled) => Effect.sync(() => { @@ -300,7 +314,7 @@ describe("ShellTool", () => { reset() const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024 return withTool(data.path, tmp.path, (registry) => - settleTool(registry, call({ command: `head -c ${bytes} /dev/zero | tr '\\0' 'x'` }, "call-overflow")), + settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")), ).pipe( Effect.andThen((settled) => Effect.sync(() => { @@ -326,7 +340,7 @@ describe("ShellTool", () => { ([data, tmp]) => { reset() return withTool(data.path, tmp.path, (registry) => - settleTool(registry, call({ command: "sleep 60", timeout: 50 })), + settleTool(registry, call({ command: idleCommand, timeout: 50 })), ).pipe( Effect.andThen((settled) => Effect.sync(() => { diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index e9e8e8e18d..e879c99d7a 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -22,6 +22,7 @@ import { Authorization } from "./middleware/authorization" import { LocationGroup } from "./groups/location" import { IntegrationGroup } from "./groups/integration" import { CredentialGroup } from "./groups/credential" +import { ProjectGroup } from "./groups/project" import { ProjectCopyGroup } from "./groups/project-copy" // Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream. @@ -47,6 +48,7 @@ const makeApiFromGroup = < .add(ProviderGroup.middleware(locationMiddleware)) .add(IntegrationGroup.middleware(locationMiddleware)) .add(CredentialGroup.middleware(locationMiddleware)) + .add(ProjectGroup.middleware(locationMiddleware)) .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware)) .add(FileSystemGroup.middleware(locationMiddleware)) .add(CommandGroup.middleware(locationMiddleware)) diff --git a/packages/protocol/src/groups/project.ts b/packages/protocol/src/groups/project.ts new file mode 100644 index 0000000000..7fbf7ac94d --- /dev/null +++ b/packages/protocol/src/groups/project.ts @@ -0,0 +1,42 @@ +import { Project } from "@opencode-ai/schema/project" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +const root = "/api/project" + +export const ProjectGroup = HttpApiGroup.make("server.project") + .add( + HttpApiEndpoint.get("project.current", `${root}/current`, { + query: LocationQuery, + success: Project.Current, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.project.current", + summary: "Get current project", + description: "Resolve the project for the requested location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("project.directories", `${root}/:projectID/directories`, { + params: { projectID: Project.ID }, + query: LocationQuery, + success: Project.Directories, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.project.directories", + summary: "List project directories", + description: "List known local absolute directories for a project.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "projects", + description: "Location-scoped project routes.", + }), + ) diff --git a/packages/schema/src/project.ts b/packages/schema/src/project.ts index 7e73530a6d..e854482de3 100644 --- a/packages/schema/src/project.ts +++ b/packages/schema/src/project.ts @@ -2,13 +2,29 @@ export * as Project from "./project" import { Schema } from "effect" import { define, inventory } from "./event" -import { NonNegativeInt, optional } from "./schema" +import { AbsolutePath, NonNegativeInt, optional } from "./schema" import { ProjectID } from "./project-id" export const ID = ProjectID export type ID = typeof ID.Type export const Vcs = Schema.Literal("git").annotate({ identifier: "Project.Vcs" }) +export const Current = Schema.Struct({ + id: ID, + directory: AbsolutePath, +}).annotate({ identifier: "Project.Current" }) +export interface Current extends Schema.Schema.Type {} +export const Directory = Schema.Struct({ + directory: AbsolutePath, + strategy: optional(Schema.String), +}).annotate({ identifier: "Project.Directory" }) +export interface Directory extends Schema.Schema.Type {} +export const DirectoriesInput = Schema.Struct({ + projectID: ID, +}).annotate({ identifier: "Project.DirectoriesInput" }) +export interface DirectoriesInput extends Schema.Schema.Type {} +export const Directories = Schema.Array(Directory).annotate({ identifier: "Project.Directories" }) +export type Directories = typeof Directories.Type export const Icon = Schema.Struct({ url: optional(Schema.String), override: optional(Schema.String), diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index cf83dbb288..e9455c350b 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -7,7 +7,6 @@ import { Project } from "../src/project" import { Pty } from "../src/pty" import { Question } from "../src/question" import { Session } from "../src/session" -import { SessionEvent } from "../src/session-event" import { SessionTodo } from "../src/session-todo" import { optional } from "../src/schema" @@ -41,6 +40,10 @@ describe("contract hygiene", () => { Model.Capabilities, Model.Cost, Model.Api, + Project.Current, + Project.Directory, + Project.DirectoriesInput, + Project.Directories, Project.Icon, Project.Commands, Project.Time, diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index fbf11b5d79..b7269495fd 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -18,6 +18,7 @@ import { ReferenceHandler } from "./handlers/reference" import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" +import { ProjectHandler } from "./handlers/project" import { ProjectCopyHandler } from "./handlers/project-copy" export const handlers = Layer.mergeAll( @@ -31,6 +32,7 @@ export const handlers = Layer.mergeAll( ProviderHandler, IntegrationHandler, CredentialHandler, + ProjectHandler, PermissionHandler, FileSystemHandler, CommandHandler, diff --git a/packages/server/src/handlers/project.ts b/packages/server/src/handlers/project.ts new file mode 100644 index 0000000000..c35b019b41 --- /dev/null +++ b/packages/server/src/handlers/project.ts @@ -0,0 +1,17 @@ +import { Location } from "@opencode-ai/core/location" +import { Project } from "@opencode-ai/core/project" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" + +export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handlers) => + handlers + .handle("project.current", () => + Location.Service.use((location) => + Effect.succeed({ id: location.project.id, directory: location.project.directory }), + ), + ) + .handle("project.directories", (ctx) => + Project.Service.use((project) => project.directories({ projectID: ctx.params.projectID })), + ), +) diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 200c18d97a..e4f29686d5 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -17,18 +17,18 @@ import { useCommandShortcut } from "../keymap" import { useProject } from "../context/project" import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" -import type { ProjectDirectories } from "@opencode-ai/sdk/v2" +import type { ProjectDirectoriesOutput } from "@opencode-ai/client" import { useRoute } from "../context/route" export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } -type ProjectDirectory = ProjectDirectories[number] +type ProjectDirectory = ProjectDirectoriesOutput[number] type DialogMoveSessionProps = { projectID: string current?: MoveSessionSelection onSelect: (selection: MoveSessionSelection) => void onCurrentChange?: (selection: MoveSessionSelection) => void - initialDirectories?: ProjectDirectory[] + initialDirectories?: ReadonlyArray initialRemoving?: string } @@ -58,12 +58,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { // A failed current-checkout lookup only affects which row is highlighted, so // swallow it and let the directory list render without a current marker. + // Once the current project is known, a mismatch is a guaranteed miss. const [loadedProject] = createResource( - () => (projectContext.project() === props.projectID ? undefined : props.projectID), + () => (projectContext.project() === undefined ? props.projectID : undefined), (projectID) => - sdk.client.project - .current({}, { throwOnError: true }) - .then((result) => (result.data?.id === projectID ? result.data.worktree : undefined)) + sdk.api.project + .current({ location: { directory: projectContext.instance.directory() || paths.cwd } }) + .then((project) => (project.id === projectID ? project.directory : undefined)) .catch(() => undefined), ) const currentCheckout = createMemo(() => { @@ -73,15 +74,19 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const [directories, { refetch }] = createResource( () => (props.initialRemoving ? undefined : props.projectID), - async (projectID, info): Promise => { + async (projectID, info): Promise | undefined> => { try { + const location = { directory: projectContext.instance.directory() || paths.cwd } await sdk.api.projectCopies.refresh({ projectID, - location: { directory: projectContext.instance.directory() || paths.cwd }, + location, + }) + const directories = await sdk.api.project.directories({ + projectID, + location, }) - const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) setLoadError(undefined) - return directories.data ?? [] + return directories } catch (error) { setLoadError(error) // An initial load with no data surfaces the inline error view below. A diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx index a613078306..97d5125392 100644 --- a/packages/tui/src/context/project.tsx +++ b/packages/tui/src/context/project.tsx @@ -37,18 +37,17 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex async function sync() { const workspace = store.workspace.current + const location = { workspace } const [instancePath, project] = await Promise.all([ sdk.client.path.get({ workspace }), - sdk.client.project.current({ workspace }), + sdk.api.project.current({ location }), ]) - const directories = project.data?.id - ? await sdk.client.project.directories({ projectID: project.data.id, workspace }) - : undefined + const directories = await sdk.api.project.directories({ projectID: project.id, location }) batch(() => { setStore("instance", "path", reconcile(instancePath.data || defaultPath)) - setStore("project", "id", project.data?.id) - setStore("project", "worktree", project.data?.worktree) - setStore("project", "mainDir", directories?.data?.findLast((item) => item.strategy === undefined)?.directory) + setStore("project", "id", project.id) + setStore("project", "worktree", project.directory) + setStore("project", "mainDir", directories.findLast((item) => item.strategy === undefined)?.directory) }) } diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index 8cb704d626..079617db6f 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -93,6 +93,9 @@ export function createFetch(override?: FetchHandler, events?: ReturnType Date: Mon, 29 Jun 2026 14:20:18 -0400 Subject: [PATCH 04/27] feat(tui): add composer tabs --- packages/tui/src/context/data.tsx | 4 + .../tui/src/routes/session/composer/index.tsx | 183 +++++++++++++ .../src/routes/session/composer/shell-tab.tsx | 141 ++++++++++ .../routes/session/composer/subagents-tab.tsx | 258 ++++++++++++++++++ 4 files changed, 586 insertions(+) create mode 100644 packages/tui/src/routes/session/composer/index.tsx create mode 100644 packages/tui/src/routes/session/composer/shell-tab.tsx create mode 100644 packages/tui/src/routes/session/composer/subagents-tab.tsx diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 32a673f506..c14516b0af 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -626,6 +626,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }), ) }, + async remove(id: string) { + await sdk.client.v2.shell.remove({ id }, { throwOnError: true }) + setStore("shell", id, undefined!) + }, }, location: { default() { diff --git a/packages/tui/src/routes/session/composer/index.tsx b/packages/tui/src/routes/session/composer/index.tsx new file mode 100644 index 0000000000..ba0fb7ae83 --- /dev/null +++ b/packages/tui/src/routes/session/composer/index.tsx @@ -0,0 +1,183 @@ +import { createEffect, createMemo, For, onCleanup, Show, useContext, createContext } from "solid-js" +import { createStore } from "solid-js/store" +import { TextAttributes } from "@opentui/core" +import { useTheme } from "../../../context/theme" +import { SplitBorder } from "../../../ui/border" +import { useBindings, useOpencodeModeStack, useCommandShortcut } from "../../../keymap" +import { SubagentsTab } from "./subagents-tab" +import { ShellTab } from "./shell-tab" + +export interface ComposerHint { + label: string + shortcut: string +} + +interface Tab { + id: string + label: string + hints?: () => ComposerHint[] + onClose?: () => void +} + +const ComposerContext = createContext<{ + register: (tab: Tab) => () => void + active: (id: string) => boolean +}>() + +export function useComposerTab() { + const ctx = useContext(ComposerContext) + if (!ctx) throw new Error("useComposerTab must be used within a Composer") + return ctx +} + +export type ComposerProps = { + sessionID: string + open: boolean + defaultTab?: string + onClose?: () => void +} + +export function Composer(props: ComposerProps) { + const { theme } = useTheme() + + const [store, setStore] = createStore({ + tabs: {} as Record, + active: "", + }) + + const tabList = createMemo(() => Object.values(store.tabs)) + const activeTab = createMemo(() => tabList().find((t) => t.id === store.active)) + const footerHints = createMemo(() => activeTab()?.hints?.() ?? []) + + // Set active tab when opened + createEffect(() => { + if (!props.open) return + const tabs = tabList() + if (tabs.length === 0) return + const match = props.defaultTab && tabs.find((t) => t.id === props.defaultTab) + setStore("active", match ? match.id : tabs[0].id) + }) + + function close() { + const tab = activeTab() + tab?.onClose?.() + props.onClose?.() + } + + const ctx = { + register(tab: Tab) { + setStore("tabs", tab.id, tab) + if (!store.active) setStore("active", tab.id) + return () => setStore("tabs", tab.id, undefined!) + }, + active(id: string) { + return props.open && store.active === id + }, + } + + const modeStack = useOpencodeModeStack() + createEffect(() => { + if (!props.open) return + const popMode = modeStack.push("composer") + onCleanup(popMode) + }) + + const switchTab = (dir: number) => { + const tabs = tabList() + if (tabs.length <= 1) return + const idx = tabs.findIndex((t) => t.id === store.active) + setStore("active", tabs[(idx + dir + tabs.length) % tabs.length].id) + } + + useBindings(() => ({ + mode: "composer", + enabled: () => props.open, + bindings: [ + { key: "left", desc: "Previous tab", group: "Composer", cmd: () => switchTab(-1) }, + { key: "right", desc: "Next tab", group: "Composer", cmd: () => switchTab(1) }, + { key: "escape", desc: "Close composer", group: "Composer", cmd: close }, + { + key: "down", + desc: "Toggle composer", + group: "Composer", + cmd: close, + }, + ], + })) + + const closeHint = useCommandShortcut("session.child_first") + + return ( + + + + + 1} + fallback={ + + + {tabList()[0]?.label ?? ""} + + + } + > + + + {(t) => { + const isActive = createMemo(() => store.active === t.id) + return ( + + {t.label} + + ) + }} + + + + + + + + {(hint) => ( + + + {hint.label}{" "} + + {hint.shortcut} + + )} + + 1}> + + + tabs{" "} + + ←/→ + + + + + close{" "} + + {closeHint()} + + + + + + + ) +} diff --git a/packages/tui/src/routes/session/composer/shell-tab.tsx b/packages/tui/src/routes/session/composer/shell-tab.tsx new file mode 100644 index 0000000000..caa76ca798 --- /dev/null +++ b/packages/tui/src/routes/session/composer/shell-tab.tsx @@ -0,0 +1,141 @@ +import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core" +import { useData } from "../../../context/data" +import { useTheme, selectedForeground } from "../../../context/theme" +import { useBindings, useCommandShortcut } from "../../../keymap" +import { useComposerTab } from "./index" + +export function ShellTab(props: { sessionID: string }) { + const data = useData() + const { theme } = useTheme() + const fg = selectedForeground(theme) + const composer = useComposerTab() + const killHint = useCommandShortcut("composer.shell.kill") + const backgroundHint = useCommandShortcut("composer.background") + + const entries = createMemo(() => + data.shell + .list() + .filter((shell) => shell.metadata.sessionID === props.sessionID && shell.status === "running"), + ) + + const [store, setStore] = createStore({ selected: 0 }) + let scroll: ScrollBoxRenderable | undefined + + const selectedEntry = createMemo(() => entries()[store.selected]) + + createEffect(() => { + if (store.selected >= entries().length) setStore("selected", Math.max(0, entries().length - 1)) + }) + + createEffect(() => { + if (!scroll) return + const target = scroll.getChildren()[store.selected] + if (!target) return + const y = target.y - scroll.y + if (y >= scroll.height || y < 0) { + const center = Math.floor(scroll.height / 2) + scroll.scrollBy(y - center) + } + }) + + onMount(() => { + const cleanup = composer.register({ + id: "shell", + label: "Shell", + hints: () => + selectedEntry() + ? [ + { label: "kill", shortcut: killHint() }, + { label: "background", shortcut: backgroundHint() }, + ] + : [], + }) + onCleanup(cleanup) + }) + + useBindings(() => ({ + mode: "composer", + enabled: () => composer.active("shell"), + commands: [ + { + name: "composer.shell.up", + title: "Previous shell", + category: "Composer", + run() { + const list = entries() + if (list.length === 0) return + setStore("selected", (prev) => (prev - 1 + list.length) % list.length) + }, + }, + { + name: "composer.shell.down", + title: "Next shell", + category: "Composer", + run() { + const list = entries() + if (list.length === 0) return + setStore("selected", (prev) => (prev + 1) % list.length) + }, + }, + { + name: "composer.shell.kill", + title: "Kill shell command", + category: "Composer", + run() { + const entry = selectedEntry() + if (!entry) return + void data.shell.remove(entry.id) + }, + }, + { + name: "composer.background", + title: "Background shell command", + category: "Composer", + run() {}, + }, + ], + bindings: [ + { key: "up", desc: "Previous shell", group: "Shell", cmd: "composer.shell.up" }, + { key: "down", desc: "Next shell", group: "Shell", cmd: "composer.shell.down" }, + { key: "ctrl+d", desc: "Kill shell command", group: "Shell", cmd: "composer.shell.kill" }, + { key: "ctrl+b", desc: "Background shell command", group: "Shell", cmd: "composer.background" }, + ], + })) + + return ( + + (scroll = r)} + > + 0} fallback={ No shell commands}> + + {(shell, index) => { + const active = createMemo(() => index() === store.selected) + return ( + setStore("selected", index())} + > + + {shell.command} + + + ) + }} + + + + + ) +} diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx new file mode 100644 index 0000000000..9090a06542 --- /dev/null +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -0,0 +1,258 @@ +import { createMemo, For, Show, createEffect, onMount, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { TextAttributes, RGBA, ScrollBoxRenderable } from "@opentui/core" +import { useRoute, useRouteData } from "../../../context/route" +import { useData } from "../../../context/data" +import { useTheme, selectedForeground } from "../../../context/theme" +import { Locale } from "../../../util/locale" +import { useBindings, useCommandShortcut } from "../../../keymap" +import { useComposerTab } from "./index" + +interface SubagentEntry { + sessionID: string + agent: string + title: string + status: string + current: boolean +} + +export function SubagentsTab(props: { sessionID: string }) { + const route = useRouteData("session") + const data = useData() + const { theme } = useTheme() + const fg = selectedForeground(theme) + const navigate = useRoute().navigate + const composer = useComposerTab() + const interruptHint = useCommandShortcut("composer.subagent.interrupt") + const backgroundHint = useCommandShortcut("composer.background") + + const session = createMemo(() => data.session.get(props.sessionID)) + + const entries = createMemo(() => { + const current = session() + if (!current) return [] + + const result: SubagentEntry[] = [] + + if (current.parentID) { + const siblings = data.session.list().filter((s) => s.parentID === current.parentID) + for (const sibling of siblings) { + const agentMatch = sibling.title.match(/@(\w+) subagent/) + const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" + const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title + result.push({ + sessionID: sibling.id, + agent, + title: name, + status: data.session.status(sibling.id), + current: sibling.id === route.sessionID, + }) + } + } else { + const children = data.session.list().filter((s) => s.parentID === props.sessionID) + for (const child of children) { + const agentMatch = child.title.match(/@(\w+) subagent/) + const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent" + const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title + result.push({ + sessionID: child.id, + agent, + title: name, + status: data.session.status(child.id), + current: child.id === route.sessionID, + }) + } + } + + return result + }) + + const [store, setStore] = createStore({ selected: 0 }) + let selectedSessionID = "" + let wasActive = false + let scroll: ScrollBoxRenderable | undefined + + const selected = createMemo(() => { + return store.selected + }) + const selectedEntry = createMemo(() => entries()[selected()]) + + createEffect(() => { + const active = composer.active("subagents") + if (!active) { + if (wasActive) { + selectedSessionID = "" + setStore("selected", 0) + } + wasActive = false + return + } + const list = entries() + if (selectedSessionID !== route.sessionID && list.length > 0) { + const currentIdx = list.findIndex((e) => e.current) + const next = currentIdx >= 0 ? currentIdx : 0 + selectedSessionID = route.sessionID + setStore("selected", next) + const scrollCurrentIntoView = () => scrollToIndex(next, true) + scrollCurrentIntoView() + requestAnimationFrame(scrollCurrentIntoView) + } + wasActive = true + if (store.selected >= list.length) moveTo(Math.max(0, list.length - 1)) + }) + + function moveTo(next: number, center = false) { + setStore("selected", next) + scrollToSelection(center) + } + + function scrollToSelection(center: boolean) { + scrollToIndex(selected(), center) + } + + function scrollToIndex(index: number, center: boolean) { + if (!scroll) return + if (center) { + scroll.scrollTo(Math.max(0, index - Math.floor(scroll.viewport.height / 2))) + return + } + if (index >= scroll.scrollTop + scroll.viewport.height) { + scroll.scrollTo(index - scroll.viewport.height + 1) + } + if (index < scroll.scrollTop) { + scroll.scrollTo(index) + if (index === 0) scroll.scrollTo(0) + } + } + + onMount(() => { + const cleanup = composer.register({ + id: "subagents", + label: "Subagents", + hints: () => { + const entry = selectedEntry() + if (!entry || entry.status !== "running") return [] + return [ + { label: "interrupt", shortcut: interruptHint() }, + ...(entry.current ? [{ label: "background", shortcut: backgroundHint() }] : []), + ] + }, + onClose: () => { + const parentID = session()?.parentID + if (parentID) navigate({ type: "session", sessionID: parentID }) + }, + }) + onCleanup(cleanup) + }) + + useBindings(() => ({ + mode: "composer", + enabled: () => composer.active("subagents"), + commands: [ + { + name: "composer.subagent.up", + title: "Previous subagent", + category: "Composer", + run() { + const list = entries() + if (list.length === 0) return + moveTo((store.selected - 1 + list.length) % list.length, true) + }, + }, + { + name: "composer.subagent.down", + title: "Next subagent", + category: "Composer", + run() { + const list = entries() + if (list.length === 0) return + moveTo((store.selected + 1) % list.length, true) + }, + }, + { + name: "composer.subagent.select", + title: "Navigate to subagent", + category: "Composer", + run() { + const entry = entries()[store.selected] + if (entry) navigate({ type: "session", sessionID: entry.sessionID }) + }, + }, + { + name: "composer.subagent.interrupt", + title: "Interrupt subagent", + category: "Composer", + run() { + const entry = selectedEntry() + if (!entry || entry.status !== "running") return + }, + }, + { + name: "composer.background", + title: "Background subagent", + category: "Composer", + run() { + const entry = selectedEntry() + if (!entry || entry.status !== "running" || !entry.current) return + }, + }, + ], + bindings: [ + { key: "up", desc: "Previous subagent", group: "Subagents", cmd: "composer.subagent.up" }, + { key: "down", desc: "Next subagent", group: "Subagents", cmd: "composer.subagent.down" }, + { key: "return", desc: "Navigate to subagent", group: "Subagents", cmd: "composer.subagent.select" }, + { key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "composer.subagent.interrupt" }, + { key: "ctrl+b", desc: "Background subagent", group: "Subagents", cmd: "composer.background" }, + ], + })) + + return ( + + (scroll = r)} + > + 0} fallback={No subagents}> + + {(entry, index) => { + const active = createMemo(() => index() === selected()) + const status = createMemo(() => { + if (entry.status === "running") return "Running" + return "" + }) + return ( + setStore("selected", index())} + onMouseUp={() => { + setStore("selected", index()) + navigate({ type: "session", sessionID: entry.sessionID }) + }} + > + + + {entry.agent}: {entry.title} + + + + + {status()} + + + + ) + }} + + + + + ) +} From c65a7d50c10eae97df94a626e6bdccbc1bab7171 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 29 Jun 2026 15:37:09 -0400 Subject: [PATCH 05/27] feat(tui): integrate composer picker --- packages/cli/AGENTS.md | 10 +-- packages/tui/src/config/keybind.ts | 2 +- packages/tui/src/routes/session/index.tsx | 86 ++++++++++++++--------- 3 files changed, 57 insertions(+), 41 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index c7215121e5..209f03d623 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -8,7 +8,7 @@ ```bash # From packages/cli: local V2 TUI -termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone +termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev # Released legacy TUI behavior reference termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest @@ -23,12 +23,12 @@ termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png ## Interactive debugging - This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI. -- Run commands from `packages/cli`. Use `bun dev --standalone` for most debugging so the TUI starts with a private V2 server instead of depending on the background service. +- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server. - Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots. - Use a dedicated session name and do not reuse or kill an unrelated session. ```bash -termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone +termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev termctrl wait opencode-v2-dev "Ask anything" --timeout 20000 termctrl show opencode-v2-dev ``` @@ -56,7 +56,7 @@ termctrl show opencode-v2-dev ``` - Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change. -- To exercise background-service behavior, omit `--standalone`. Service lifecycle commands are available through `bun dev service start`, `bun dev service status`, and `bun dev service stop`. +- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`. - Always clean up the Terminal Control session when the check is complete: ```bash @@ -85,7 +85,7 @@ bun dev api --param key=value ```bash termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \ - bun run --inspect=ws://localhost:6499/ src/index.ts --standalone + bun run --inspect=ws://localhost:6499/ src/index.ts ``` - Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches. diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 4dbf872750..878ed34093 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -99,7 +99,7 @@ export const Definitions = { session_toggle_timestamps: keybind("none", "Toggle message timestamps"), session_toggle_generic_tool_output: keybind("none", "Toggle generic tool output"), session_queued_prompts: keybind("q", "Manage queued prompts"), - session_child_first: keybind("down", "Go to first child session"), + session_child_first: keybind("down", "Toggle subagent picker"), session_child_cycle: keybind("right", "Go to next child session"), session_child_cycle_reverse: keybind("left", "Go to previous child session"), session_parent: keybind("up", "Go to parent session"), diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 3c521a6b1e..ee6f4809ef 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -16,6 +16,7 @@ import { import path from "node:path" import { mkdir, writeFile } from "node:fs/promises" import { useRoute, useRouteData } from "../../context/route" +import { createStore } from "solid-js/store" import { useProject } from "../../context/project" import { useData } from "../../context/data" import { SplitBorder } from "../../ui/border" @@ -46,7 +47,7 @@ import { DialogSessionRename } from "../../component/dialog-session-rename" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" import { Sidebar } from "./sidebar" -import { SubagentFooter } from "./subagent-footer.tsx" +import { Composer } from "./composer" import { filetype } from "../../util/filetype" import parsers from "../../parsers-config" import { errorMessage } from "../../util/error" @@ -177,7 +178,10 @@ export function Session() { if (session()?.parentID) return [] return data.session.question.list(route.sessionID) ?? [] }) - const visible = createMemo(() => !session()?.parentID && permissions().length === 0 && questions().length === 0) + const [composer, setComposer] = createStore({ + open: false, + tab: undefined as string | undefined, + }) const disabled = createMemo(() => permissions().length > 0 || questions().length > 0) const pending = createMemo(() => { @@ -764,11 +768,14 @@ export function Session() { run: () => unavailable("Backgrounding subagents"), }, { - title: "Go to child session", + title: "Toggle subagent picker", value: "session.child.first", category: "Session", - hidden: true, - run: () => unavailable("Child session discovery"), + run: () => { + if (composer.open || session()?.parentID) setComposer("open", false) + else setComposer("open", true) + dialog.clear() + }, }, { title: "Go to parent session", @@ -836,6 +843,7 @@ export function Session() { // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) + createEffect(on(() => route.sessionID, () => setComposer("open", false))) return ( @@ -898,37 +906,45 @@ export function Session() { - 0}> - - - 0}> - - - - - - - - setComposer("open", false)} + /> + + + {null} + + 0}> + + + 0}> + + + + { - toBottom() - }} - sessionID={route.sessionID} - right={} - /> - - + > + { + toBottom() + }} + sessionID={route.sessionID} + right={} + /> + + + From 360d85a521691d59487356c096e658e09a84f88f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 29 Jun 2026 15:46:51 -0400 Subject: [PATCH 06/27] fix(tui): indent subagent empty state --- packages/tui/src/routes/session/composer/subagents-tab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tui/src/routes/session/composer/subagents-tab.tsx b/packages/tui/src/routes/session/composer/subagents-tab.tsx index 9090a06542..a1c77147da 100644 --- a/packages/tui/src/routes/session/composer/subagents-tab.tsx +++ b/packages/tui/src/routes/session/composer/subagents-tab.tsx @@ -213,7 +213,7 @@ export function SubagentsTab(props: { sessionID: string }) { maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)} > - 0} fallback={No subagents}> + 0} fallback={ No subagents}> {(entry, index) => { const active = createMemo(() => index() === selected()) From b2d46ecd7ede61f4b593a300f9953cadb9e109b3 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 29 Jun 2026 16:13:51 -0400 Subject: [PATCH 07/27] feat(core): add durable session fork event --- .../client/src/generated-effect/client.ts | 158 ++++++++-------- packages/client/src/generated/client.ts | 14 ++ packages/client/src/generated/types.ts | 93 +++++++++- packages/core/src/event.ts | 20 +- packages/core/src/session.ts | 66 ++++++- packages/core/src/session/message-updater.ts | 1 + packages/core/src/session/projector.ts | 172 +++++++++++++++++- packages/core/test/session-create.test.ts | 91 ++++++++- packages/protocol/src/groups/session.ts | 17 ++ packages/schema/src/session-event.ts | 18 ++ packages/server/src/handlers/session.ts | 26 +++ 11 files changed, 588 insertions(+), 88 deletions(-) diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index ed041ed50f..30047f4a04 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -86,45 +86,56 @@ const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Inp Effect.map((value) => value.data), ) -type Endpoint3_4Request = Parameters[0] +type Endpoint3_4Request = Parameters[0] type Endpoint3_4Input = { readonly sessionID: Endpoint3_4Request["params"]["sessionID"] - readonly agent: Endpoint3_4Request["payload"]["agent"] + readonly messageID?: Endpoint3_4Request["payload"]["messageID"] } const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) => + raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint3_5Request = Parameters[0] +type Endpoint3_5Input = { + readonly sessionID: Endpoint3_5Request["params"]["sessionID"] + readonly agent: Endpoint3_5Request["payload"]["agent"] +} +const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) => raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint3_5Request = Parameters[0] -type Endpoint3_5Input = { - readonly sessionID: Endpoint3_5Request["params"]["sessionID"] - readonly model: Endpoint3_5Request["payload"]["model"] +type Endpoint3_6Request = Parameters[0] +type Endpoint3_6Input = { + readonly sessionID: Endpoint3_6Request["params"]["sessionID"] + readonly model: Endpoint3_6Request["payload"]["model"] } -const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) => +const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) => raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint3_6Request = Parameters[0] -type Endpoint3_6Input = { - readonly sessionID: Endpoint3_6Request["params"]["sessionID"] - readonly title: Endpoint3_6Request["payload"]["title"] +type Endpoint3_7Request = Parameters[0] +type Endpoint3_7Input = { + readonly sessionID: Endpoint3_7Request["params"]["sessionID"] + readonly title: Endpoint3_7Request["payload"]["title"] } -const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) => +const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) => raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint3_7Request = Parameters[0] -type Endpoint3_7Input = { - readonly sessionID: Endpoint3_7Request["params"]["sessionID"] - readonly id?: Endpoint3_7Request["payload"]["id"] - readonly prompt: Endpoint3_7Request["payload"]["prompt"] - readonly delivery?: Endpoint3_7Request["payload"]["delivery"] - readonly resume?: Endpoint3_7Request["payload"]["resume"] +type Endpoint3_8Request = Parameters[0] +type Endpoint3_8Input = { + readonly sessionID: Endpoint3_8Request["params"]["sessionID"] + readonly id?: Endpoint3_8Request["payload"]["id"] + readonly prompt: Endpoint3_8Request["payload"]["prompt"] + readonly delivery?: Endpoint3_8Request["payload"]["delivery"] + readonly resume?: Endpoint3_8Request["payload"]["resume"] } -const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) => +const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) => raw["session.prompt"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, @@ -133,23 +144,23 @@ const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Inp Effect.map((value) => value.data), ) -type Endpoint3_8Request = Parameters[0] -type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] } -const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) => - raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint3_9Request = Parameters[0] +type Endpoint3_9Request = Parameters[0] type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] } const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_10Request = Parameters[0] +type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] } +const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_10Request = Parameters[0] -type Endpoint3_10Input = { - readonly sessionID: Endpoint3_10Request["params"]["sessionID"] - readonly messageID: Endpoint3_10Request["payload"]["messageID"] - readonly files?: Endpoint3_10Request["payload"]["files"] +type Endpoint3_11Request = Parameters[0] +type Endpoint3_11Input = { + readonly sessionID: Endpoint3_11Request["params"]["sessionID"] + readonly messageID: Endpoint3_11Request["payload"]["messageID"] + readonly files?: Endpoint3_11Request["payload"]["files"] } -const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) => +const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -158,42 +169,42 @@ const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10I Effect.map((value) => value.data), ) -type Endpoint3_11Request = Parameters[0] -type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] } -const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint3_12Request = Parameters[0] +type Endpoint3_12Request = Parameters[0] type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] } const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_13Request = Parameters[0] +type Endpoint3_13Request = Parameters[0] type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] } const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_14Request = Parameters[0] +type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] } +const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint3_14Request = Parameters[0] -type Endpoint3_14Input = { - readonly sessionID: Endpoint3_14Request["params"]["sessionID"] - readonly limit?: Endpoint3_14Request["query"]["limit"] - readonly after?: Endpoint3_14Request["query"]["after"] +type Endpoint3_15Request = Parameters[0] +type Endpoint3_15Input = { + readonly sessionID: Endpoint3_15Request["params"]["sessionID"] + readonly limit?: Endpoint3_15Request["query"]["limit"] + readonly after?: Endpoint3_15Request["query"]["after"] } -const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) => +const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) => raw["session.history"]({ params: { sessionID: input["sessionID"] }, query: { limit: input["limit"], after: input["after"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_15Request = Parameters[0] -type Endpoint3_15Input = { - readonly sessionID: Endpoint3_15Request["params"]["sessionID"] - readonly after?: Endpoint3_15Request["query"]["after"] +type Endpoint3_16Request = Parameters[0] +type Endpoint3_16Input = { + readonly sessionID: Endpoint3_16Request["params"]["sessionID"] + readonly after?: Endpoint3_16Request["query"]["after"] } -const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) => +const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) => Stream.unwrap( raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe( Effect.mapError(mapClientError), @@ -201,17 +212,17 @@ const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15I ), ) -type Endpoint3_16Request = Parameters[0] -type Endpoint3_16Input = { readonly sessionID: Endpoint3_16Request["params"]["sessionID"] } -const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) => +type Endpoint3_17Request = Parameters[0] +type Endpoint3_17Input = { readonly sessionID: Endpoint3_17Request["params"]["sessionID"] } +const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) => raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_17Request = Parameters[0] -type Endpoint3_17Input = { - readonly sessionID: Endpoint3_17Request["params"]["sessionID"] - readonly messageID: Endpoint3_17Request["params"]["messageID"] +type Endpoint3_18Request = Parameters[0] +type Endpoint3_18Input = { + readonly sessionID: Endpoint3_18Request["params"]["sessionID"] + readonly messageID: Endpoint3_18Request["params"]["messageID"] } -const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) => +const Endpoint3_18 = (raw: RawClient["server.session"]) => (input: Endpoint3_18Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -222,20 +233,21 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({ create: Endpoint3_1(raw), active: Endpoint3_2(raw), get: Endpoint3_3(raw), - switchAgent: Endpoint3_4(raw), - switchModel: Endpoint3_5(raw), - rename: Endpoint3_6(raw), - prompt: Endpoint3_7(raw), - compact: Endpoint3_8(raw), - wait: Endpoint3_9(raw), - stage: Endpoint3_10(raw), - clear: Endpoint3_11(raw), - commit: Endpoint3_12(raw), - context: Endpoint3_13(raw), - history: Endpoint3_14(raw), - events: Endpoint3_15(raw), - interrupt: Endpoint3_16(raw), - message: Endpoint3_17(raw), + fork: Endpoint3_4(raw), + switchAgent: Endpoint3_5(raw), + switchModel: Endpoint3_6(raw), + rename: Endpoint3_7(raw), + prompt: Endpoint3_8(raw), + compact: Endpoint3_9(raw), + wait: Endpoint3_10(raw), + stage: Endpoint3_11(raw), + clear: Endpoint3_12(raw), + commit: Endpoint3_13(raw), + context: Endpoint3_14(raw), + history: Endpoint3_15(raw), + events: Endpoint3_16(raw), + interrupt: Endpoint3_17(raw), + message: Endpoint3_18(raw), }) type Endpoint4_0Request = Parameters[0] diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index e0ceb3bf2a..f7825f55fa 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -11,6 +11,8 @@ import type { SessionsActiveOutput, SessionsGetInput, SessionsGetOutput, + SessionsForkInput, + SessionsForkOutput, SessionsSwitchAgentInput, SessionsSwitchAgentOutput, SessionsSwitchModelInput, @@ -361,6 +363,18 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + fork: (input: SessionsForkInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionsForkOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`, + body: { messageID: input["messageID"] }, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 50aa222117..3c2bd6e5c4 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -33,6 +33,15 @@ export type SessionNotFoundError = { export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionNotFoundError" +export type MessageNotFoundError = { + readonly _tag: "MessageNotFoundError" + readonly sessionID: string + readonly messageID: string + readonly message: string +} +export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError" + export type ConflictError = { readonly _tag: "ConflictError" readonly message: string @@ -65,15 +74,6 @@ export type UnknownError = { export const isUnknownError = (value: unknown): value is UnknownError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnknownError" -export type MessageNotFoundError = { - readonly _tag: "MessageNotFoundError" - readonly sessionID: string - readonly messageID: string - readonly message: string -} -export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError => - typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError" - export type ProviderNotFoundError = { readonly _tag: "ProviderNotFoundError" readonly providerID: string @@ -377,6 +377,45 @@ export type SessionsGetOutput = { } }["data"] +export type SessionsForkInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly messageID?: { readonly messageID?: string | undefined }["messageID"] +} + +export type SessionsForkOutput = { + readonly data: { + readonly id: string + readonly parentID?: string + readonly projectID: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } + readonly title: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subpath?: string + readonly revert?: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } +}["data"] + export type SessionsSwitchAgentInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly agent: { readonly agent: string }["agent"] @@ -750,6 +789,24 @@ export type SessionsHistoryOutput = { readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.forked" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly parentID: string + readonly slug: string + readonly title: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly messageID?: string + readonly copiedSeq: number + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -1216,6 +1273,24 @@ export type SessionsEventsOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.forked" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly parentID: string + readonly slug: string + readonly title: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly messageID?: string + readonly copiedSeq: number + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index c9195be79f..877b6aa8bf 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -3,7 +3,7 @@ export * as EventV2 from "./event" import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect" import { Event } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event" -import { and, asc, eq, gt, inArray } from "drizzle-orm" +import { and, asc, eq, gt, inArray, sql } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" @@ -31,6 +31,22 @@ export const latestSequence = Effect.fn("EventV2.latestSequence")(function* ( return row?.seq ?? -1 }) +export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* ( + db: Database.Interface["db"], + aggregateID: string, + seq: number, +) { + yield* db + .insert(EventSequenceTable) + .values([{ aggregate_id: aggregateID, seq }]) + .onConflictDoUpdate({ + target: EventSequenceTable.aggregate_id, + set: { seq: sql`max(${EventSequenceTable.seq}, ${seq})` }, + }) + .run() + .pipe(Effect.orDie) +}) + export type SerializedEvent = { readonly id: ID readonly type: string @@ -327,7 +343,7 @@ export const layerWith = (options?: LayerOptions) => .onConflictDoUpdate({ target: EventSequenceTable.aggregate_id, set: { - seq, + seq: sql`max(${EventSequenceTable.seq}, ${seq})`, ...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}), }, }) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 1323c8aa3a..79da32aca7 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -90,6 +90,11 @@ type CompactInput = { sessionID: SessionSchema.ID } +type ForkInput = { + sessionID: SessionSchema.ID + messageID?: SessionMessage.ID +} + export class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { sessionID: SessionSchema.ID, }) {} @@ -113,11 +118,18 @@ export class BusyError extends Schema.TaggedErrorClass()("Session.Bus export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError -export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError | BusyError +export type Error = + | NotFoundError + | MessageDecodeError + | OperationUnavailableError + | PromptConflictError + | BusyError + | MessageNotFoundError export interface Interface { readonly list: (input?: ListInput) => Effect.Effect readonly create: (input: CreateInput) => Effect.Effect + readonly fork: (input: ForkInput) => Effect.Effect readonly get: (sessionID: SessionSchema.ID) => Effect.Effect readonly messages: (input: { sessionID: SessionSchema.ID @@ -272,6 +284,52 @@ export const layer = Layer.effect( // TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice. return yield* result.get(sessionID).pipe(Effect.orDie) }), + fork: Effect.fn("V2Session.fork")(function* (input) { + const parent = yield* result.get(input.sessionID) + const boundary = input.messageID + ? yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)), + ) + .get() + .pipe(Effect.orDie) + : undefined + if (input.messageID && !boundary) + return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID }) + const copied = yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, input.sessionID), + boundary === undefined ? undefined : lt(SessionMessageTable.seq, boundary.seq), + ), + ) + .orderBy(desc(SessionMessageTable.seq)) + .limit(1) + .get() + .pipe(Effect.orDie) + const sessionID = SessionSchema.ID.create() + yield* events.publish(SessionEvent.Forked, { + sessionID, + parentID: parent.id, + slug: Slug.create(), + title: forkTitle(parent.title), + agent: parent.agent, + model: parent.model, + messageID: input.messageID, + copiedSeq: copied?.seq ?? 0, + timestamp: yield* DateTime.now, + }, { + commit: (seq) => + copied && copied.seq > seq + ? EventV2.reserveSequence(db, sessionID, copied.seq) + : Effect.void, + }) + return yield* result.get(sessionID).pipe(Effect.orDie) + }), get: Effect.fn("V2Session.get")(function* (sessionID) { const session = yield* store.get(sessionID) if (!session) return yield* new NotFoundError({ sessionID }) @@ -505,6 +563,12 @@ export const defaultLayer = layer.pipe( Layer.orDie, ) +const forkTitle = (value: string) => { + const match = value.match(/^(.+) \(fork #(\d+)\)$/) + if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})` + return `${value} (fork #1)` +} + const resolvePrompt = (input: PromptInput.Prompt) => Prompt.make({ text: input.text, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 3269aa1d72..cfb424620d 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -124,6 +124,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }, "session.next.moved": () => Effect.void, "session.next.renamed": () => Effect.void, + "session.next.forked": () => Effect.void, "session.next.prompted": (event) => { return adapter.appendMessage( SessionMessage.User.make({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index b202f94211..47acd7d937 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -1,6 +1,6 @@ export * as SessionProjector from "./projector" -import { and, desc, eq, gt, or, sql } from "drizzle-orm" +import { and, asc, desc, eq, gt, inArray, lt, or, sql } from "drizzle-orm" import { DateTime, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" @@ -17,6 +17,7 @@ import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, Sessio import type { DeepMutable } from "../schema" type DatabaseService = Database.Interface["db"] +type MessageEvent = Exclude const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message) @@ -33,6 +34,13 @@ type Usage = { } } +const ForkBatchSize = 500 + +const emptyUsage = (): Usage => ({ + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, +}) + function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] | unknown): Usage | undefined { if (typeof part !== "object" || part === null) return undefined const value = part as Record @@ -41,6 +49,22 @@ function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] | return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] } } +function addUsage(target: Usage, value: Usage) { + target.cost += value.cost + target.tokens.input += value.tokens.input + target.tokens.output += value.tokens.output + target.tokens.reasoning += value.tokens.reasoning + target.tokens.cache.read += value.tokens.cache.read + target.tokens.cache.write += value.tokens.cache.write +} + +function messageUsage(row: typeof SessionMessageTable.$inferSelect): Usage | undefined { + if (row.type !== "assistant") return undefined + const message = decodeMessage({ ...row.data, id: row.id, type: row.type }) + if (message.type !== "assistant" || message.cost === undefined || message.tokens === undefined) return undefined + return { cost: message.cost, tokens: message.tokens } +} + function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert { return { id: info.id, @@ -109,7 +133,150 @@ function applyUsage( .pipe(Effect.orDie) } -function run(db: DatabaseService, event: SessionEvent.Event) { +const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( + db: DatabaseService, + event: typeof SessionEvent.Forked.Type, +) { + const parent = yield* db + .select() + .from(SessionTable) + .where(eq(SessionTable.id, event.data.parentID)) + .get() + .pipe(Effect.orDie) + if (!parent) return yield* Effect.die(`Fork parent session not found: ${event.data.parentID}`) + + const stored = yield* db + .insert(SessionTable) + .values({ + id: event.data.sessionID, + parent_id: event.data.parentID, + project_id: parent.project_id, + workspace_id: parent.workspace_id, + slug: event.data.slug, + directory: parent.directory, + path: parent.path, + title: event.data.title, + agent: event.data.agent, + model: event.data.model, + version: parent.version, + cost: 0, + tokens_input: 0, + tokens_output: 0, + tokens_reasoning: 0, + tokens_cache_read: 0, + tokens_cache_write: 0, + time_created: DateTime.toEpochMillis(event.data.timestamp), + time_updated: DateTime.toEpochMillis(event.data.timestamp), + }) + .onConflictDoNothing() + .returning({ sessionID: SessionTable.id }) + .get() + .pipe(Effect.orDie) + if (!stored) return yield* Effect.die(new SessionAlreadyProjected()) + + const usage = emptyUsage() + let cursor = -1 + while (true) { + const rows = yield* db + .select() + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, event.data.parentID), + gt(SessionMessageTable.seq, cursor), + event.data.messageID === undefined ? undefined : lt(SessionMessageTable.seq, event.data.copiedSeq + 1), + ), + ) + .orderBy(asc(SessionMessageTable.seq)) + .limit(ForkBatchSize) + .all() + .pipe(Effect.orDie) + if (rows.length === 0) break + + const idMap = new Map(rows.map((row) => [row.id, SessionMessage.ID.create()])) + yield* db + .insert(SessionMessageTable) + .values( + rows.map((row) => { + const id = idMap.get(row.id) + if (!id) throw new Error(`Fork message ID mapping missing: ${row.id}`) + return { + id, + session_id: event.data.sessionID, + type: row.type, + seq: row.seq, + time_created: row.time_created, + time_updated: row.time_updated, + data: row.type === "synthetic" ? { ...row.data, sessionID: event.data.sessionID } : row.data, + } + }), + ) + .run() + .pipe(Effect.orDie) + + const inputRows = yield* db + .select() + .from(SessionInputTable) + .where( + and( + eq(SessionInputTable.session_id, event.data.parentID), + inArray( + SessionInputTable.id, + rows.map((row) => row.id), + ), + ), + ) + .all() + .pipe(Effect.orDie) + if (inputRows.length > 0) { + yield* db + .insert(SessionInputTable) + .values( + inputRows.flatMap((row) => { + const id = idMap.get(row.id) + return id + ? [ + { + id, + session_id: event.data.sessionID, + prompt: row.prompt, + delivery: row.delivery, + admitted_seq: row.admitted_seq, + promoted_seq: row.promoted_seq, + time_created: row.time_created, + }, + ] + : [] + }), + ) + .run() + .pipe(Effect.orDie) + } + + for (const row of rows) { + const value = messageUsage(row) + if (value) addUsage(usage, value) + } + cursor = rows.at(-1)!.seq + } + + yield* db + .update(SessionTable) + .set({ + cost: usage.cost, + tokens_input: usage.tokens.input, + tokens_output: usage.tokens.output, + tokens_reasoning: usage.tokens.reasoning, + tokens_cache_read: usage.tokens.cache.read, + tokens_cache_write: usage.tokens.cache.write, + }) + .where(eq(SessionTable.id, event.data.sessionID)) + .run() + .pipe(Effect.orDie) + if (event.data.copiedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, event.data.copiedSeq) +}) + +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 }) @@ -355,6 +522,7 @@ export const layer = Layer.effectDiscard( .run() .pipe(Effect.orDie), ) + yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event)) yield* events.project(SessionEvent.Prompted, (event) => Effect.gen(function* () { if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence") diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index f573d968d6..df7e29db1e 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import path from "path" -import { Effect, Layer, Stream } from "effect" +import { DateTime, Effect, Layer, Stream } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" @@ -20,6 +20,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { WorkspaceV2 } from "@opencode-ai/core/workspace" @@ -131,6 +132,94 @@ describe("SessionV2.create", () => { }), ) + it.effect("forks a session by replaying a durable fork event into copied projected rows", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const parent = yield* session.create({ location, title: "Parent" }) + const admitted = yield* session.prompt({ + sessionID: parent.id, + prompt: Prompt.make({ text: "First" }), + resume: false, + }) + yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER) + yield* events.publish(SessionEvent.Synthetic, { + sessionID: parent.id, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + text: "parent note", + }) + + const forked = yield* session.fork({ sessionID: parent.id }) + const parentContext = yield* session.context(parent.id) + const forkContext = yield* session.context(forked.id) + const history = yield* session.history({ sessionID: forked.id, limit: 10 }) + + expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" }) + expect(forkContext).toMatchObject([ + { type: "user", text: "First" }, + { type: "synthetic", text: "parent note", sessionID: forked.id }, + ]) + expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id)) + expect(history.events).toHaveLength(1) + expect(history.events[0]).toMatchObject({ + type: "session.next.forked", + durable: { seq: 0 }, + data: { sessionID: forked.id, parentID: parent.id, copiedSeq: 3 }, + }) + expect(yield* SessionInput.find(db, forkContext[0]!.id)).toMatchObject({ + sessionID: forked.id, + prompt: { text: "First" }, + promotedSeq: 2, + }) + + yield* session.prompt({ sessionID: parent.id, prompt: Prompt.make({ text: "Parent changed" }), resume: false }) + yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER) + yield* session.prompt({ sessionID: forked.id, prompt: Prompt.make({ text: "Child continues" }), resume: false }) + yield* SessionInput.promoteSteers(db, events, forked.id, Number.MAX_SAFE_INTEGER) + + expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) + expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"]) + expect((yield* session.context(forked.id)).at(-1)).toMatchObject({ text: "Child continues" }) + expect((yield* session.history({ sessionID: forked.id, limit: 10 })).events.map((event) => event.durable?.seq)).toEqual([ + 0, + 4, + 5, + ]) + expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id }) + }), + ) + + it.effect("forks before the selected boundary message", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const parent = yield* session.create({ location }) + const first = yield* session.prompt({ + sessionID: parent.id, + prompt: Prompt.make({ text: "First" }), + resume: false, + }) + yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER) + const second = yield* session.prompt({ + sessionID: parent.id, + prompt: Prompt.make({ text: "Second" }), + resume: false, + }) + yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER) + + const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id }) + + const context = yield* session.context(forked.id) + const history = yield* session.history({ sessionID: forked.id, limit: 10 }) + expect(context).toMatchObject([{ text: "First" }]) + expect(context[0]?.id).not.toBe(first.id) + expect(history.events[0]).toMatchObject({ data: { copiedSeq: 2, messageID: second.id } }) + }), + ) + it.effect("returns the existing Session when one ID is reused with different create arguments", () => Effect.gen(function* () { const session = yield* SessionV2.Service diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index c33f2076fe..5706e14dbf 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -170,6 +170,23 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.fork", "/api/session/:sessionID/fork", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ messageID: SessionMessage.ID.pipe(Schema.optional) }), + success: Schema.Struct({ data: Session.Info }), + error: [SessionNotFoundError, MessageNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.fork", + summary: "Fork session", + description: + "Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary.", + }), + ), + ) .add( HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", { params: { sessionID: Session.ID }, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index f27457a29d..0322af14c2 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -94,6 +94,22 @@ export const Renamed = Event.define({ }) export type Renamed = typeof Renamed.Type +export const Forked = Event.define({ + type: "session.next.forked", + ...options, + schema: { + ...Base, + parentID: SessionID, + slug: Schema.String, + title: Schema.String, + agent: Schema.String.pipe(optional), + model: Model.Ref.pipe(optional), + messageID: SessionMessage.ID.pipe(optional), + copiedSeq: NonNegativeInt, + }, +}) +export type Forked = typeof Forked.Type + export const Prompted = Event.define({ type: "session.next.prompted", ...options, @@ -460,6 +476,7 @@ export const DurableDefinitions = Event.inventory( ModelSwitched, Moved, Renamed, + Forked, Prompted, PromptAdmitted, ContextUpdated, @@ -492,6 +509,7 @@ export const Definitions = Event.inventory( ModelSwitched, Moved, Renamed, + Forked, Prompted, PromptAdmitted, ContextUpdated, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index cf092cc5e1..9be10c57ba 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -107,6 +107,32 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.fork", + Effect.fn(function* (ctx) { + return { + data: yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }).pipe( + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + Effect.catchTag( + "Session.MessageNotFoundError", + (error) => + new MessageNotFoundError({ + sessionID: error.sessionID, + messageID: error.messageID, + message: `Message not found: ${error.messageID}`, + }), + ), + ), + } + }), + ) .handle( "session.switchAgent", Effect.fn(function* (ctx) { From ff4cab03c14204e2f3ca8a5aeefbb702064ce215 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 29 Jun 2026 16:16:13 -0400 Subject: [PATCH 08/27] refactor(core): simplify session fork event --- packages/client/src/generated/types.ts | 10 ------ packages/core/src/session.ts | 29 --------------- packages/core/src/session/projector.ts | 44 +++++++++++++++++++---- packages/core/test/session-create.test.ts | 4 +-- packages/schema/src/session-event.ts | 5 --- 5 files changed, 40 insertions(+), 52 deletions(-) diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3c2bd6e5c4..1bada8512c 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -799,12 +799,7 @@ export type SessionsHistoryOutput = { readonly timestamp: number readonly sessionID: string readonly parentID: string - readonly slug: string - readonly title: string - readonly agent?: string - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly messageID?: string - readonly copiedSeq: number } } | { @@ -1283,12 +1278,7 @@ export type SessionsEventsOutput = readonly timestamp: number readonly sessionID: string readonly parentID: string - readonly slug: string - readonly title: string - readonly agent?: string - readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly messageID?: string - readonly copiedSeq: number } } | { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 79da32aca7..4384180b68 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -298,35 +298,12 @@ export const layer = Layer.effect( : undefined if (input.messageID && !boundary) return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID }) - const copied = yield* db - .select({ seq: SessionMessageTable.seq }) - .from(SessionMessageTable) - .where( - and( - eq(SessionMessageTable.session_id, input.sessionID), - boundary === undefined ? undefined : lt(SessionMessageTable.seq, boundary.seq), - ), - ) - .orderBy(desc(SessionMessageTable.seq)) - .limit(1) - .get() - .pipe(Effect.orDie) const sessionID = SessionSchema.ID.create() yield* events.publish(SessionEvent.Forked, { sessionID, parentID: parent.id, - slug: Slug.create(), - title: forkTitle(parent.title), - agent: parent.agent, - model: parent.model, messageID: input.messageID, - copiedSeq: copied?.seq ?? 0, timestamp: yield* DateTime.now, - }, { - commit: (seq) => - copied && copied.seq > seq - ? EventV2.reserveSequence(db, sessionID, copied.seq) - : Effect.void, }) return yield* result.get(sessionID).pipe(Effect.orDie) }), @@ -563,12 +540,6 @@ export const defaultLayer = layer.pipe( Layer.orDie, ) -const forkTitle = (value: string) => { - const match = value.match(/^(.+) \(fork #(\d+)\)$/) - if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})` - return `${value} (fork #1)` -} - const resolvePrompt = (input: PromptInput.Prompt) => Prompt.make({ text: input.text, diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 47acd7d937..a9a069cf6a 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -15,6 +15,7 @@ import { WorkspaceV2 } from "../workspace" import { SessionContextEpoch } from "./context-epoch" import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql" import type { DeepMutable } from "../schema" +import { Slug } from "../util/slug" type DatabaseService = Database.Interface["db"] type MessageEvent = Exclude @@ -41,6 +42,12 @@ const emptyUsage = (): Usage => ({ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, }) +const forkTitle = (value: string) => { + const match = value.match(/^(.+) \(fork #(\d+)\)$/) + if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})` + return `${value} (fork #1)` +} + function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] | unknown): Usage | undefined { if (typeof part !== "object" || part === null) return undefined const value = part as Record @@ -144,6 +151,31 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .get() .pipe(Effect.orDie) if (!parent) return yield* Effect.die(`Fork parent session not found: ${event.data.parentID}`) + const boundary = event.data.messageID + ? yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.messageID)), + ) + .get() + .pipe(Effect.orDie) + : undefined + if (event.data.messageID && !boundary) return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`) + const copied = yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, event.data.parentID), + boundary === undefined ? undefined : lt(SessionMessageTable.seq, boundary.seq), + ), + ) + .orderBy(desc(SessionMessageTable.seq)) + .limit(1) + .get() + .pipe(Effect.orDie) + const copiedSeq = copied?.seq ?? 0 const stored = yield* db .insert(SessionTable) @@ -152,12 +184,12 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( parent_id: event.data.parentID, project_id: parent.project_id, workspace_id: parent.workspace_id, - slug: event.data.slug, + slug: Slug.create(), directory: parent.directory, path: parent.path, - title: event.data.title, - agent: event.data.agent, - model: event.data.model, + title: forkTitle(parent.title), + agent: parent.agent, + model: parent.model, version: parent.version, cost: 0, tokens_input: 0, @@ -184,7 +216,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( and( eq(SessionMessageTable.session_id, event.data.parentID), gt(SessionMessageTable.seq, cursor), - event.data.messageID === undefined ? undefined : lt(SessionMessageTable.seq, event.data.copiedSeq + 1), + copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1), ), ) .orderBy(asc(SessionMessageTable.seq)) @@ -273,7 +305,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) - if (event.data.copiedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, event.data.copiedSeq) + if (copiedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq) }) function run(db: DatabaseService, event: MessageEvent) { diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index df7e29db1e..16bdfd68b4 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -166,7 +166,7 @@ describe("SessionV2.create", () => { expect(history.events[0]).toMatchObject({ type: "session.next.forked", durable: { seq: 0 }, - data: { sessionID: forked.id, parentID: parent.id, copiedSeq: 3 }, + data: { sessionID: forked.id, parentID: parent.id }, }) expect(yield* SessionInput.find(db, forkContext[0]!.id)).toMatchObject({ sessionID: forked.id, @@ -216,7 +216,7 @@ describe("SessionV2.create", () => { const history = yield* session.history({ sessionID: forked.id, limit: 10 }) expect(context).toMatchObject([{ text: "First" }]) expect(context[0]?.id).not.toBe(first.id) - expect(history.events[0]).toMatchObject({ data: { copiedSeq: 2, messageID: second.id } }) + expect(history.events[0]).toMatchObject({ data: { messageID: second.id } }) }), ) diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 0322af14c2..a66881a8ad 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -100,12 +100,7 @@ export const Forked = Event.define({ schema: { ...Base, parentID: SessionID, - slug: Schema.String, - title: Schema.String, - agent: Schema.String.pipe(optional), - model: Model.Ref.pipe(optional), messageID: SessionMessage.ID.pipe(optional), - copiedSeq: NonNegativeInt, }, }) export type Forked = typeof Forked.Type From 19a5b5a05dacdc24f938ac15015a0f802013d66d Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 29 Jun 2026 17:01:04 -0400 Subject: [PATCH 09/27] feat(core): support background shell tool --- packages/core/src/session.ts | 11 ++ packages/core/src/tool/builtins.ts | 4 - packages/core/src/tool/shell.ts | 231 ++++++++++++++++------ packages/core/src/tool/subagent.ts | 12 +- packages/core/test/tool-shell.test.ts | 271 +++++++++++++++----------- packages/server/src/routes.ts | 2 + 6 files changed, 341 insertions(+), 190 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4384180b68..f1e6b47edd 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -188,6 +188,7 @@ export interface Interface { readonly active: Effect.Effect> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect + readonly synthetic: (input: { sessionID: SessionSchema.ID; text: string }) => Effect.Effect readonly revert: { readonly stage: (input: { sessionID: SessionSchema.ID @@ -497,6 +498,16 @@ export const layer = Layer.effect( yield* result.get(sessionID) yield* execution.resume(sessionID) }), + synthetic: Effect.fn("V2Session.synthetic")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.Synthetic, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + text: input.text, + }) + yield* execution.wake(input.sessionID) + }), interrupt: Effect.fn("V2Session.interrupt")((sessionID) => Effect.uninterruptible(execution.interrupt(sessionID)), ), diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index 8fa53b48af..b69e2edfe7 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -2,7 +2,6 @@ export * as BuiltInTools from "./builtins" import { makeLocationNode } from "../effect/app-node" import { Layer } from "effect" -import { ShellTool } from "./shell" import { ApplyPatchTool } from "./apply-patch" import { EditTool } from "./edit" import { GlobTool } from "./glob" @@ -16,7 +15,6 @@ import { WebFetchTool } from "./webfetch" import { WebSearchTool } from "./websearch" import { WriteTool } from "./write" import { FSUtil } from "../fs-util" -import { Shell } from "../shell" import { Location } from "../location" import { LocationMutation } from "../location-mutation" import { FileMutation } from "../file-mutation" @@ -44,7 +42,6 @@ import { httpClient } from "../effect/app-node-platform" */ export const locationLayer = Layer.mergeAll( ApplyPatchTool.layer, - ShellTool.layer, EditTool.layer, GlobTool.layer, GrepTool.layer, @@ -63,7 +60,6 @@ export const node = makeLocationNode({ deps: [ ToolRegistry.toolsNode, FSUtil.node, - Shell.node, Location.node, LocationMutation.node, FileMutation.node, diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index fbc884b696..7db6ccfa4e 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -2,20 +2,28 @@ export * as ShellTool from "./shell" import path from "path" import { ToolFailure } from "@opencode-ai/llm" -import { Effect, Layer, Schema } from "effect" +import { Effect, Layer, Schema, Scope } from "effect" +import { BackgroundJob } from "../background-job" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" +import { LocationServiceMap } from "../location-service-map" import { PermissionV2 } from "../permission" import { PositiveInt } from "../schema" +import { SessionV2 } from "../session" +import { SessionSchema } from "../session/schema" import { Shell } from "../shell" -import { Tool } from "./tool" -import { Tools } from "./tools" +import { Tool, type Content } from "./tool" +import { ApplicationTools } from "./application-tools" +import { makeGlobalNode } from "../effect/app-node" export const name = "shell" export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000 export const MAX_TIMEOUT_MS = 10 * 60 * 1_000 export const MAX_CAPTURE_BYTES = 1024 * 1024 +const BACKGROUND_STARTED = + "The command is running in the background. You will be notified automatically when it completes. DO NOT sleep, poll, or proactively check on its progress." + export const Input = Schema.Struct({ command: Schema.String.annotate({ description: "Shell command string to execute" }), workdir: Schema.String.pipe(Schema.optional).annotate({ @@ -26,6 +34,10 @@ export const Input = Schema.Struct({ .annotate({ description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`, }), + background: Schema.Boolean.pipe(Schema.optional).annotate({ + description: + "Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.", + }), }) const StructuredOutput = Schema.Struct({ @@ -37,12 +49,14 @@ const StructuredOutput = Schema.Struct({ const Output = Schema.Struct({ ...StructuredOutput.fields, output: Schema.String, + status: Schema.Literals(["completed", "running"]).pipe(Schema.optional), warnings: Schema.Array(Schema.String).pipe(Schema.optional), }) type Output = typeof Output.Type -const modelOutput = (output: Output) => { +const modelOutput = (output: Output): string | undefined => { + if (output.status === "running") return undefined const warnings = output.warnings?.length ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` : "" @@ -61,7 +75,6 @@ const modelOutput = (output: Output) => { // TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist. // TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired. // TODO: Persist background job status and define restart recovery before exposing remote observation. -// TODO: Re-add model-facing background launch only with owner-bound get/wait/cancel tools and completion delivery. // TODO: Add HTTP background-job observation only after durable status, restart recovery, and authorization are defined. // TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it. // TODO: Revisit binary output handling if stdout/stderr decoding is text-only. @@ -83,16 +96,47 @@ const externalCommandDirectories = (command: string, cwd: string) => { export const layer = Layer.effectDiscard( Effect.gen(function* () { - const tools = yield* Tools.Service - const mutation = yield* LocationMutation.Service - const fs = yield* FSUtil.Service - const shell = yield* Shell.Service - const permission = yield* PermissionV2.Service + const tools = yield* ApplicationTools.Service + const sessions = yield* SessionV2.Service + const jobs = yield* BackgroundJob.Service + const locations = yield* LocationServiceMap.Service + const scope = yield* Scope.Scope + + const injectWhenDone = Effect.fn("ShellTool.injectWhenDone")(function* ( + sessionID: SessionSchema.ID, + callID: string, + command: string, + ) { + yield* jobs.wait({ id: callID }).pipe( + Effect.flatMap((result) => { + const state = + result.info?.status === "completed" + ? "completed" + : result.info?.status === "error" + ? "error" + : result.info?.status === "cancelled" + ? "cancelled" + : undefined + if (state === undefined) return Effect.void + const text = + state === "completed" + ? result.info!.output ?? "" + : state === "error" + ? result.info!.error ?? "Command failed" + : "Command cancelled" + return sessions.synthetic({ + sessionID, + text: `\n${text}\n`, + }) + }), + Effect.forkIn(scope, { startImmediately: true }), + ) + }) yield* tools .register({ [name]: Tool.make({ - description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, + description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, input: Input, output: Output, structured: StructuredOutput, @@ -101,76 +145,133 @@ export const layer = Layer.effectDiscard( ...(output.exit === undefined ? {} : { exit: output.exit }), ...(output.timeout === undefined ? {} : { timeout: output.timeout }), }), - toModelOutput: ({ output }) => [ - { type: "text", text: output.output }, - { type: "text", text: modelOutput(output) }, - ], + toModelOutput: ({ output }) => { + const parts: Content[] = [{ type: "text", text: output.output }] + const model = modelOutput(output) + if (model) parts.push({ type: "text", text: model }) + return parts + }, execute: (input, context) => Effect.gen(function* () { - const source = { - type: "tool" as const, - messageID: context.assistantMessageID, - callID: context.toolCallID, - } - const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" }) - const external = target.externalDirectory - if (external) + const parent = yield* sessions + .get(context.sessionID) + .pipe( + Effect.mapError(() => new ToolFailure({ message: `Session not found: ${context.sessionID}` })), + ) + return yield* Effect.gen(function* () { + const mutation = yield* LocationMutation.Service + const fs = yield* FSUtil.Service + const shell = yield* Shell.Service + const permission = yield* PermissionV2.Service + const source = { + type: "tool" as const, + messageID: context.assistantMessageID, + callID: context.toolCallID, + } + const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" }) + const external = target.externalDirectory + if (external) + yield* permission.assert({ + ...LocationMutation.externalDirectoryPermission(external), + sessionID: context.sessionID, + agent: context.agent, + source, + }) + const warnings = externalCommandDirectories(input.command, target.canonical).map( + (directory) => + `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`, + ) yield* permission.assert({ - ...LocationMutation.externalDirectoryPermission(external), + action: name, + resources: [input.command], + save: [input.command], sessionID: context.sessionID, agent: context.agent, source, }) - const warnings = externalCommandDirectories(input.command, target.canonical).map( - (directory) => - `Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`, - ) - yield* permission.assert({ - action: name, - resources: [input.command], - save: [input.command], - sessionID: context.sessionID, - agent: context.agent, - source, - }) - if ((yield* fs.stat(target.canonical)).type !== "Directory") - return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) + if ((yield* fs.stat(target.canonical)).type !== "Directory") + return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) - // Delegate spawning, combined-output capture, timeout, and exit tracking to the Shell - // service. The full output is captured to a file; we read a bounded page for the model - // and point the agent at the file when it overflows the model cap. - const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS - const info = yield* shell.create({ - command: input.command, - cwd: target.canonical, - timeout, - metadata: { sessionID: context.sessionID }, - }) - const final = yield* shell.wait(info.id) - const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) + const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS - if (final.status === "timeout") { + if (input.background === true) { + const run = Effect.fn("ShellTool.run")(function* () { + const info = yield* shell.create({ + command: input.command, + cwd: target.canonical, + timeout, + metadata: { sessionID: context.sessionID }, + }) + const final = yield* shell.wait(info.id) + const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) + + if (final.status === "timeout") + return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.` + + const truncated = page.size > page.cursor + const body = page.output || "(no output)" + const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" + return `${body}${notice}` + }) + + const info = yield* jobs.start({ + id: context.toolCallID, + type: name, + title: input.command, + metadata: { sessionID: context.sessionID }, + onPromote: injectWhenDone(context.sessionID, context.toolCallID, input.command), + run: run(), + }) + yield* injectWhenDone(context.sessionID, context.toolCallID, input.command) + return { + output: BACKGROUND_STARTED, + truncated: false, + status: "running" as const, + ...(warnings.length ? { warnings } : {}), + } + } + + const info = yield* shell.create({ + command: input.command, + cwd: target.canonical, + timeout, + metadata: { sessionID: context.sessionID }, + }) + const final = yield* shell.wait(info.id) + const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) + + if (final.status === "timeout") { + return { + exit: final.exit, + output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, + truncated: false, + timeout: true, + status: "completed" as const, + ...(warnings.length ? { warnings } : {}), + } + } + + const truncated = page.size > page.cursor + const body = page.output || "(no output)" + const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" return { - output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, - truncated: false, - timeout: true, + exit: final.exit, + output: `${body}${notice}`, + truncated, + status: "completed" as const, ...(warnings.length ? { warnings } : {}), } - } - - const truncated = page.size > page.cursor - const body = page.output || "(no output)" - const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" - return { - exit: final.exit, - output: `${body}${notice}`, - truncated, - ...(warnings.length ? { warnings } : {}), - } + }).pipe(Effect.provide(locations.get(parent.location))) as Effect.Effect, ToolFailure> }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), }), }) .pipe(Effect.orDie) }), ) + +export const node = makeGlobalNode({ + name: "shell-tool", + layer, + deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, LocationServiceMap.node], +}) diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 23b502a90c..f5e8ebcd84 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -1,14 +1,11 @@ export * as SubagentTool from "./subagent" import { ToolFailure } from "@opencode-ai/llm" -import { DateTime, Effect, Layer, Schema, Scope } from "effect" +import { Effect, Layer, Schema, Scope } from "effect" import { AgentV2 } from "../agent" import { BackgroundJob } from "../background-job" -import { EventV2 } from "../event" import { LocationServiceMap } from "../location-service-map" import { SessionV2 } from "../session" -import { SessionEvent } from "../session/event" -import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" import { makeGlobalNode } from "../effect/app-node" import { ApplicationTools } from "./application-tools" @@ -48,7 +45,6 @@ export const layer = Layer.effectDiscard( const tools = yield* ApplicationTools.Service const sessions = yield* SessionV2.Service const jobs = yield* BackgroundJob.Service - const events = yield* EventV2.Service const locations = yield* LocationServiceMap.Service const scope = yield* Scope.Scope @@ -75,10 +71,8 @@ export const layer = Layer.effectDiscard( state: "completed" | "error" | "cancelled", text: string, ) { - yield* events.publish(SessionEvent.Synthetic, { + yield* sessions.synthetic({ sessionID: parentID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, text: `\n${text}\n`, }) }) @@ -188,5 +182,5 @@ export const layer = Layer.effectDiscard( export const node = makeGlobalNode({ name: "subagent-tool", layer, - deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, EventV2.node, LocationServiceMap.node], + deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, LocationServiceMap.node], }) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 8f760aa123..d9243ad0c5 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -2,26 +2,38 @@ import fs from "fs/promises" import { realpathSync } from "node:fs" import path from "path" import { describe, expect, test } from "bun:test" -import { Effect, Layer } from "effect" -import { Config } from "@opencode-ai/core/config" +import { DateTime, Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { filesystem } from "@opencode-ai/core/effect/app-node-platform" +import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" -import { LocationMutation } from "@opencode-ai/core/location-mutation" -import { PermissionV2 } from "@opencode-ai/core/permission" -import { AppProcess } from "@opencode-ai/core/process" -import { Project } from "@opencode-ai/core/project" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { AgentV2 } from "@opencode-ai/core/agent" +import { BackgroundJob } from "@opencode-ai/core/background-job" import { SessionV2 } from "@opencode-ai/core/session" -import { Shell } from "@opencode-ai/core/shell" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { SessionStore } from "@opencode-ai/core/session/store" +import { PermissionV2 } from "@opencode-ai/core/permission" import { ShellTool } from "@opencode-ai/core/tool/shell" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" const sessionID = SessionV2.ID.make("ses_shell_tool_test") +const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") }) const assertions: PermissionV2.AssertInput[] = [] let denyAction: string | undefined let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect => Effect.void @@ -50,37 +62,80 @@ const reset = () => { afterPermission = () => Effect.void } -const withTool = ( - data: string, - directory: string, - body: (registry: ToolRegistry.Interface) => Effect.Effect, -) => { - const filesystem = FSUtil.defaultLayer - const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe( - Layer.provide(Project.defaultLayer), - ) - const global = Global.layerWith({ data, config: path.join(data, "config") }) - const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(location)) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const shellService = Shell.layer.pipe( - Layer.provide(EventV2.defaultLayer), - Layer.provide(location), - Layer.provide(Config.locationLayer.pipe(Layer.provide(location), Layer.provide(filesystem), Layer.provide(global))), - Layer.provide(global), - Layer.provide(filesystem), - Layer.provide(AppProcess.defaultLayer), - ) - const shell = ShellTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(mutation), - Layer.provide(filesystem), - Layer.provide(shellService), - ) - return Effect.gen(function* () { - return yield* body(yield* ToolRegistry.Service) - }).pipe(Effect.provide(Layer.mergeAll(registry, shell, filesystem))) -} +const executionNode = makeGlobalNode({ + service: SessionExecution.Service, + layer: Layer.effect( + SessionExecution.Service, + Effect.gen(function* () { + const events = yield* EventV2.Service + const store = yield* SessionStore.Service + const complete = Effect.fn("ShellTest.complete")(function* (id: SessionV2.ID) { + const session = yield* store.get(id) + if (!session) return + const assistantMessageID = SessionMessage.ID.create() + const textID = "text_shell_test" + yield* events.publish(SessionEvent.Step.Started, { + sessionID: id, + assistantMessageID, + timestamp: yield* DateTime.now, + agent: session.agent ?? AgentV2.ID.make("code"), + model: sessionModel, + }) + yield* events.publish(SessionEvent.Text.Started, { + sessionID: id, + assistantMessageID, + timestamp: yield* DateTime.now, + textID, + }) + yield* events.publish(SessionEvent.Text.Ended, { + sessionID: id, + assistantMessageID, + timestamp: yield* DateTime.now, + textID, + text: "ok", + }) + yield* events.publish(SessionEvent.Step.Ended, { + sessionID: id, + assistantMessageID, + timestamp: yield* DateTime.now, + finish: "stop", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + return SessionExecution.Service.of({ + active: Effect.succeed(new Set()), + resume: complete, + wake: () => Effect.void, + interrupt: () => Effect.void, + awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid), + }) + }), + ), + deps: [EventV2.node, SessionStore.node], +}) + +const layer = AppNodeBuilder.build( + LayerNode.bind( + LayerNode.group([ + Database.node, + EventV2.node, + BackgroundJob.node, + ToolOutputStore.cleanupNode, + SessionV2.node, + ShellTool.node, + LocationServiceMap.node, + filesystem, + FSUtil.node, + Global.node, + ]), + SessionExecution.node, + executionNode, + ), + [LayerNode.replace(PermissionV2.layer, permission)], +) + +const it = testEffect(layer) const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({ sessionID, @@ -100,20 +155,42 @@ const overflowCommand = (bytes: number) => ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` : `head -c ${bytes} /dev/zero | tr '\\0' 'x'` -const it = testEffect(Layer.empty) +const withSession = ( + directory: string, + body: (registry: ToolRegistry.Interface) => Effect.Effect, +) => + Effect.gen(function* () { + const sessions = yield* SessionV2.Service + const location = Location.Ref.make({ directory: AbsolutePath.make(directory) }) + yield* sessions.create({ + id: sessionID, + title: "shell test", + location, + model: sessionModel, + }) + const locations = yield* LocationServiceMap.Service + const locationLayer = locations.get(location) + const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer)) + return yield* body(registry).pipe(Effect.provide(locationLayer)) + }) describe("ShellTool", () => { it.live("registers and returns real successful output from the active Location", () => Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([data, tmp]) => { + Effect.promise(() => tmpdir()), + (tmp) => { reset() - return withTool(data.path, tmp.path, (registry) => + return withSession(tmp.path, (registry) => Effect.gen(function* () { const definitions = yield* toolDefinitions(registry) - expect(definitions.map((tool) => tool.name)).toEqual(["shell"]) - expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output") - expect(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).toEqual([]) + const shell = definitions.find((tool) => tool.name === "shell") + expect(shell).toBeDefined() + expect(shell?.outputSchema).not.toHaveProperty("properties.output") + expect( + (yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map( + (tool) => tool.name, + ), + ).not.toContain("shell") const settled = yield* settleTool(registry, call({ command: helloCommand })) expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false }) @@ -126,21 +203,18 @@ describe("ShellTool", () => { }), ) }, - ([data, tmp]) => - Effect.promise(() => - Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined), - ), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), ), ) it.live("resolves a relative workdir from the active Location", () => Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([data, tmp]) => { + Effect.promise(() => tmpdir()), + (tmp) => { reset() return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe( Effect.andThen( - withTool(data.path, tmp.path, (registry) => + withSession(tmp.path, (registry) => settleTool(registry, call({ command: cwdCommand, workdir: "src" })), ), ), @@ -154,17 +228,14 @@ describe("ShellTool", () => { ), ) }, - ([data, tmp]) => - Effect.promise(() => - Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined), - ), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), ), ) it.live("rejects a workdir that stops being a directory during approval", () => Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([data, tmp]) => { + Effect.promise(() => tmpdir()), + (tmp) => { reset() const workdir = path.join(tmp.path, "src") afterPermission = (input) => @@ -176,26 +247,23 @@ describe("ShellTool", () => { : Effect.void return Effect.promise(() => fs.mkdir(workdir)).pipe( Effect.andThen( - withTool(data.path, tmp.path, (registry) => + withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" })), ), ), Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))), ) }, - ([data, tmp]) => - Effect.promise(() => - Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined), - ), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), ), ) it.live("approves an explicit external workdir before shell execution", () => Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])), - ([data, active, outside]) => { + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { reset() - return withTool(data.path, active.path, (registry) => + return withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: outside.path })), ).pipe( Effect.andThen( @@ -208,53 +276,45 @@ describe("ShellTool", () => { ), ) }, - ([data, active, outside]) => + ([active, outside]) => Effect.promise(() => - Promise.all([ - data[Symbol.asyncDispose](), - active[Symbol.asyncDispose](), - outside[Symbol.asyncDispose](), - ]).then(() => undefined), + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), ), ), ) it.live("does not execute after external-directory or shell denial", () => Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])), - ([data, active, outside]) => + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => Effect.gen(function* () { reset() denyAction = "external_directory" - yield* withTool(data.path, active.path, (registry) => + yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: outside.path })), ) expect(assertions.map((item) => item.action)).toEqual(["external_directory"]) reset() denyAction = "shell" - yield* withTool(data.path, active.path, (registry) => executeTool(registry, call({ command: cwdCommand }))) + yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand }))) expect(assertions.map((item) => item.action)).toEqual(["shell"]) }), - ([data, active, outside]) => + ([active, outside]) => Effect.promise(() => - Promise.all([ - data[Symbol.asyncDispose](), - active[Symbol.asyncDispose](), - outside[Symbol.asyncDispose](), - ]).then(() => undefined), + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), ), ), ) it.live("reports external command arguments as advisory warnings without enforcing approval", () => Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])), - ([data, active, outside]) => { + Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), + ([active, outside]) => { reset() denyAction = "external_directory" const target = path.join(outside.path, "secret.txt") - return withTool(data.path, active.path, (registry) => + return withSession(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` })), ).pipe( Effect.andThen((settled) => @@ -269,23 +329,19 @@ describe("ShellTool", () => { ), ) }, - ([data, active, outside]) => + ([active, outside]) => Effect.promise(() => - Promise.all([ - data[Symbol.asyncDispose](), - active[Symbol.asyncDispose](), - outside[Symbol.asyncDispose](), - ]).then(() => undefined), + Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined), ), ), ) it.live("keeps non-zero exits useful", () => Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([data, tmp]) => { + Effect.promise(() => tmpdir()), + (tmp) => { reset() - return withTool(data.path, tmp.path, (registry) => + return withSession(tmp.path, (registry) => settleTool(registry, call({ command: bodyExitCommand }, "call-nonzero")), ).pipe( Effect.andThen((settled) => @@ -300,20 +356,17 @@ describe("ShellTool", () => { ), ) }, - ([data, tmp]) => - Effect.promise(() => - Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined), - ), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), ), ) it.live("truncates the model view and points at the saved output file when output overflows", () => Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([data, tmp]) => { + Effect.promise(() => tmpdir()), + (tmp) => { reset() const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024 - return withTool(data.path, tmp.path, (registry) => + return withSession(tmp.path, (registry) => settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")), ).pipe( Effect.andThen((settled) => @@ -327,19 +380,16 @@ describe("ShellTool", () => { ), ) }, - ([data, tmp]) => - Effect.promise(() => - Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined), - ), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), ), ) it.live("returns a useful timeout settlement", () => Effect.acquireUseRelease( - Effect.promise(() => Promise.all([tmpdir(), tmpdir()])), - ([data, tmp]) => { + Effect.promise(() => tmpdir()), + (tmp) => { reset() - return withTool(data.path, tmp.path, (registry) => + return withSession(tmp.path, (registry) => settleTool(registry, call({ command: idleCommand, timeout: 50 })), ).pipe( Effect.andThen((settled) => @@ -353,10 +403,7 @@ describe("ShellTool", () => { ), ) }, - ([data, tmp]) => - Effect.promise(() => - Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined), - ), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)), ), ) }) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index a9e1040c95..668ec1b53e 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -11,6 +11,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" import { SubagentTool } from "@opencode-ai/core/tool/subagent" +import { ShellTool } from "@opencode-ai/core/tool/shell" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -31,6 +32,7 @@ const applicationServices = LayerNode.group([ ToolOutputStore.cleanupNode, SessionV2.node, SubagentTool.node, + ShellTool.node, PermissionSaved.node, PtyTicket.node, Credential.node, From 2fe057324f92f231674f947bd88d4678b8594ae8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 29 Jun 2026 17:27:24 -0400 Subject: [PATCH 10/27] fix(core): provide filesystem to shell tool --- packages/core/src/tool/shell.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 7db6ccfa4e..9371a51d4f 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -101,6 +101,7 @@ export const layer = Layer.effectDiscard( const jobs = yield* BackgroundJob.Service const locations = yield* LocationServiceMap.Service const scope = yield* Scope.Scope + const fsUtil = yield* FSUtil.Service const injectWhenDone = Effect.fn("ShellTool.injectWhenDone")(function* ( sessionID: SessionSchema.ID, @@ -160,7 +161,6 @@ export const layer = Layer.effectDiscard( ) return yield* Effect.gen(function* () { const mutation = yield* LocationMutation.Service - const fs = yield* FSUtil.Service const shell = yield* Shell.Service const permission = yield* PermissionV2.Service const source = { @@ -190,7 +190,7 @@ export const layer = Layer.effectDiscard( source, }) - if ((yield* fs.stat(target.canonical)).type !== "Directory") + if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS @@ -273,5 +273,5 @@ export const layer = Layer.effectDiscard( export const node = makeGlobalNode({ name: "shell-tool", layer, - deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, LocationServiceMap.node], + deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, LocationServiceMap.node, FSUtil.node], }) From fe59174c23eb87dac1364ad2d7cd56bef1fd4741 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 29 Jun 2026 17:32:54 -0400 Subject: [PATCH 11/27] fix(core): resume after synthetic session message --- packages/core/src/session.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index f1e6b47edd..83cc9ffca1 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,7 @@ export * as SessionV2 from "./session" export * from "./session/schema" -import { DateTime, Effect, Layer, Schema, Context, Stream } 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, like, lt, or, type SQL } from "drizzle-orm" import { ProjectV2 } from "./project" @@ -212,6 +212,7 @@ export const layer = Layer.effect( const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service const locations = yield* LocationServiceMap.Service + const scope = yield* Scope.Scope const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -506,7 +507,7 @@ export const layer = Layer.effect( timestamp: yield* DateTime.now, text: input.text, }) - yield* execution.wake(input.sessionID) + 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)), From f80624cf1705be6121f3c3541ea4dc35e612017e Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:46:50 -0500 Subject: [PATCH 12/27] fix(tui): surface provider error in assistant footer (#34511) --- packages/tui/src/routes/session/index.tsx | 35 +++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index ee6f4809ef..6109074df0 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1147,15 +1147,32 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) { props.message.time.completed ? props.message.time.completed - props.message.time.created : 0, ) return ( - - - {Locale.titlecase(props.message.agent)} - · {model()} - - · {Locale.duration(duration())} - - - + <> + + + {errorMessage(props.message.error)} + + + + + + {Locale.titlecase(props.message.agent)} + + · {model()} + + · {Locale.duration(duration())} + + + + ) } From ecfa918760ac44b64c50477954f1c6948a2ca375 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 29 Jun 2026 19:21:18 -0400 Subject: [PATCH 13/27] feat(client): expose fs read in promise client (#34504) --- packages/client/script/build.ts | 9 +- packages/client/src/contract.ts | 3 +- packages/client/src/generated/client.ts | 21 ++++ packages/client/src/generated/types.ts | 9 ++ packages/client/test/promise.test.ts | 23 +++- packages/core/src/shell.ts | 43 ++++--- packages/core/src/tool/builtins.ts | 10 +- packages/core/test/location-layer.test.ts | 2 - packages/httpapi-codegen/src/index.ts | 119 ++++++++++++++---- .../httpapi-codegen/test/generate.test.ts | 51 +++++--- 10 files changed, 227 insertions(+), 63 deletions(-) diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index aeec4b3e34..287e6fccd9 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -1,16 +1,17 @@ import { NodeFileSystem } from "@effect/platform-node" import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" -import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract" +import { ClientApi, effectOmitEndpoints, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract" import { Effect } from "effect" import { fileURLToPath } from "url" -const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints }) +const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints }) +const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints }) await Effect.runPromise( Effect.all( [ write( - emitPromise(contract, { + emitPromise(promiseContract, { outputTypes: { "events.subscribe": { name: "OpenCodeEventEncoded", @@ -21,7 +22,7 @@ await Effect.runPromise( fileURLToPath(new URL("../src/generated", import.meta.url)), ), write( - emitEffectImported(contract, { module: "../contract", api: "ClientApi" }), + emitEffectImported(effectContract, { module: "../contract", api: "ClientApi" }), fileURLToPath(new URL("../src/generated-effect", import.meta.url)), ), ], diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 61dda7c610..32f85abb97 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -52,4 +52,5 @@ export const endpointNames = { "question.request.list": "listRequests", } as const -export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) +export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"]) +export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"]) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index f7825f55fa..8a830f49b7 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -87,6 +87,8 @@ import type { PermissionsGetOutput, PermissionsReplyInput, PermissionsReplyOutput, + FilesReadInput, + FilesReadOutput, FilesListInput, FilesListOutput, FilesFindInput, @@ -155,6 +157,7 @@ interface RequestDescriptor { readonly successStatus: number readonly declaredStatuses: ReadonlyArray readonly empty: boolean + readonly binary?: true } export function make(options: ClientOptions) { @@ -200,6 +203,7 @@ export function make(options: ClientOptions) { const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => { const response = await execute(descriptor, requestOptions) if (response.status !== descriptor.successStatus) return responseError(response, descriptor) + if (descriptor.binary) return new Uint8Array(await response.arrayBuffer()) as A if (descriptor.empty) { try { await response.body?.cancel() @@ -840,6 +844,19 @@ export function make(options: ClientOptions) { ), }, files: { + read: (input: FilesReadInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/fs/read/${encodePath(input.path)}`, + query: { location: input["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + binary: true, + }, + requestOptions, + ), list: (input?: FilesListInput, requestOptions?: RequestOptions) => request( { @@ -1143,6 +1160,10 @@ export function make(options: ClientOptions) { } } +function encodePath(value: string): string { + return value.split("/").map(encodeURIComponent).join("/") +} + function appendQuery(params: URLSearchParams, key: string, value: unknown): void { if (value === undefined || value === null) return if (Array.isArray(value)) { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 1bada8512c..a375bc1e7c 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2572,6 +2572,15 @@ export type PermissionsReplyInput = { export type PermissionsReplyOutput = void +export type FilesReadInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] + readonly path: string +} + +export type FilesReadOutput = globalThis.Uint8Array + export type FilesListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index dba04e6706..d00db17d85 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -37,11 +37,32 @@ test("exposes every standard HTTP API group", () => { "attemptComplete", "attemptCancel", ]) - expect(Object.keys(client.files)).toEqual(["list", "find"]) + expect(Object.keys(client.files)).toEqual(["read", "list", "find"]) expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"]) expect(Object.keys(client.project)).toEqual(["current", "directories"]) }) +test("files.read returns binary content from the public HTTP contract", async () => { + let request: Request | undefined + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input) => { + request = input instanceof Request ? input : new Request(input) + return new Response(new Uint8Array([104, 105])) + }, + }) + + const content = await client.files.read({ + path: "src/a b#c.ts", + location: { directory: "/tmp/project" }, + }) + + expect(Array.from(content)).toEqual([104, 105]) + expect(request?.url).toBe( + "http://localhost:3000/api/fs/read/src/a%20b%23c.ts?location%5Bdirectory%5D=%2Ftmp%2Fproject", + ) +}) + test("project methods use the public HTTP contract", async () => { const requests: string[] = [] const client = OpenCode.make({ diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 8d4b254093..26fcfc4d7e 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -31,7 +31,7 @@ type Active = { // Resolves with the terminal Info once the command exits, times out, or is killed. A wait // started after termination resolves immediately from the already-completed deferred. done: Deferred.Deferred - timeoutFiber?: Fiber.Fiber + timeoutFiber?: Fiber.Fiber } /** @@ -181,7 +181,7 @@ export const layer = Layer.effect( // Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so // the managing fiber keeps its scope open until the command terminates (it awaits `done` at the // end). `create` returns once `ready` resolves with the registered session. - const ready = Deferred.makeUnsafe() + const ready = Deferred.makeUnsafe() runFork( Effect.scoped( Effect.gen(function* () { @@ -205,14 +205,7 @@ export const layer = Layer.effect( sessions.set(id, session) const stream = createWriteStream(file) - yield* Effect.promise( - () => - new Promise((resolve) => { - stream.once("open", () => resolve()) - stream.once("error", () => resolve()) - }), - ) - + const outputDone = Deferred.makeUnsafe() const pump = handle.all.pipe( Stream.runForEach((chunk: Uint8Array) => Effect.sync(() => { @@ -221,9 +214,27 @@ export const layer = Layer.effect( }), ), ) - runFork(pump.pipe(Effect.catch(() => Effect.void))) + runFork( + Effect.gen(function* () { + yield* pump.pipe(Effect.catch(() => Effect.void)) + yield* Effect.promise( + () => + new Promise((resolve) => { + stream.end(() => resolve()) + }), + ) + yield* Deferred.succeed(outputDone, undefined) + }).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))), + ) + yield* Effect.promise( + () => + new Promise((resolve) => { + stream.once("open", () => resolve()) + stream.once("error", () => resolve()) + }), + ) - const finish = (status: Info["status"], exit?: number) => + const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) => Effect.gen(function* () { if (session.info.status !== "running") return session.info = produce(session.info, (draft) => { @@ -231,7 +242,8 @@ export const layer = Layer.effect( if (exit !== undefined) draft.exit = exit draft.time.completed = Date.now() }) - stream.end() + yield* beforeWait + yield* Deferred.await(outputDone) // Resolve waiters with the terminal Info before any retention eviction, so an evicted // session still reports success rather than the removal NotFoundError. This runs before // the timeout-fiber interrupt below, which on the timeout path would otherwise cancel @@ -257,10 +269,7 @@ export const layer = Layer.effect( session.timeoutFiber = runFork( Effect.sleep(Duration.millis(input.timeout)).pipe( Effect.flatMap(() => - Effect.gen(function* () { - yield* finish("timeout") - yield* handle.kill().pipe(Effect.catch(() => Effect.void)) - }), + finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))), ), Effect.catch(() => Effect.void), ), diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index b69e2edfe7..08c6b2a1be 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -1,7 +1,7 @@ export * as BuiltInTools from "./builtins" import { makeLocationNode } from "../effect/app-node" -import { Layer } from "effect" +import { Context, Layer } from "effect" import { ApplyPatchTool } from "./apply-patch" import { EditTool } from "./edit" import { GlobTool } from "./glob" @@ -27,6 +27,8 @@ import { SessionTodo } from "../session/todo" import { ToolRegistry } from "./registry" import { httpClient } from "../effect/app-node-platform" +export class Service extends Context.Service>()("@opencode/v2/BuiltInTools") {} + /** * Composes only the shipped Location-scoped built-in tool transforms. * Each tool retains its implementation and focused tests independently. Dynamic @@ -40,7 +42,7 @@ import { httpClient } from "../effect/app-node-platform" * repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin * transforms separate from this static built-in list. */ -export const locationLayer = Layer.mergeAll( +const registrations = Layer.mergeAll( ApplyPatchTool.layer, EditTool.layer, GlobTool.layer, @@ -54,8 +56,10 @@ export const locationLayer = Layer.mergeAll( WriteTool.layer, ) +export const locationLayer = Layer.succeed(Service, Service.of({})).pipe(Layer.provideMerge(registrations)) + export const node = makeLocationNode({ - name: "built-in-tools", + service: Service, layer: locationLayer, deps: [ ToolRegistry.toolsNode, diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index ad6257a492..35616256d9 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -126,7 +126,6 @@ describe("LocationServiceMap", () => { "grep", "question", "read", - "shell", "skill", "todowrite", "webfetch", @@ -143,7 +142,6 @@ describe("LocationServiceMap", () => { "grep", "question", "read", - "shell", "skill", "todowrite", "webfetch", diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 298a1211dd..43d9e92881 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -9,10 +9,15 @@ export type InputField = { readonly source: "params" | "query" | "headers" | "payload" } +export type OperationInputField = { + readonly name: string + readonly source: InputField["source"] | "wildcard" +} + export type Operation = { readonly group: string readonly name: string - readonly input: ReadonlyArray + readonly input: ReadonlyArray readonly inputMode: "none" | "optional" | "required" readonly success: "value" | "void" | "stream" readonly errors: ReadonlyArray @@ -67,6 +72,10 @@ type Slot = { readonly schema: Schema.Top } +type PromiseInputField = + | (InputField & { readonly optional: boolean }) + | { readonly name: string; readonly source: "wildcard"; readonly optional: false } + const resolveHttpApiStatus = SchemaAST.resolveAt("httpApiStatus") const resolveHttpApiEncoding = SchemaAST.resolveAt("~httpApiEncoding") const resolveContentSchema = SchemaAST.resolveAt("contentSchema") @@ -246,7 +255,7 @@ export function emitPromise( for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint) } return { - operations: operations(groups), + operations: promiseOperations(groups), files: [ { path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) }, { @@ -255,7 +264,7 @@ export function emitPromise( }, { path: "client.ts", - content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult", "let next"), + content: normalizePromiseClientContent(renderPromiseClient(groups), groups), }, { path: "index.ts", @@ -285,11 +294,11 @@ function assertPromiseEndpoint(endpoint: Endpoint) { ) { throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` }) } - } else if ( - !HttpApiSchema.isNoContent(success.ast) && - (resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json" - ) { - throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` }) + } else if (!HttpApiSchema.isNoContent(success.ast)) { + const encoding = resolveHttpApiEncoding(success.ast)?._tag ?? "Json" + if (encoding !== "Json" && encoding !== "Uint8Array") { + throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` }) + } } for (const error of endpoint.errors) { if (declaredErrorFields(error.schema) === undefined) { @@ -305,6 +314,16 @@ function operations(groups: ReadonlyArray) { return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation)) } +function promiseOperations(groups: ReadonlyArray) { + return groups.flatMap((group) => + group.endpoints.map((endpoint) => ({ + ...endpoint.operation, + input: promiseInput(endpoint).map(({ name, source }) => ({ name, source })), + inputMode: promiseInputMode(endpoint), + })), + ) +} + function renderEffectFiles(groups: ReadonlyArray): Output["files"] { return [ ...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })), @@ -457,8 +476,9 @@ function renderPromiseTypes( headers: endpoint.headers, payload: endpoint.payloads[0], } - const input = endpoint.input + const input = promiseInput(endpoint) .map((field) => { + if (field.source === "wildcard") return `readonly ${JSON.stringify(field.name)}: string` const schema = schemas[field.source] if (schema === undefined) throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) @@ -476,7 +496,7 @@ function renderPromiseTypes( : successSchema, ) return [ - ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), + ...(promiseInputMode(endpoint) === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, ] }), @@ -493,19 +513,19 @@ function renderPromiseClient(groups: ReadonlyArray) { const imports = groups.flatMap((group) => group.endpoints.flatMap((endpoint) => { const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) - return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] + return [...(promiseInputMode(endpoint) === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`] }), ) const fields = groups.map((group) => { const methods = group.endpoints.map((endpoint) => { const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name) + const inputMode = promiseInputMode(endpoint) const argument = - endpoint.operation.inputMode === "none" + inputMode === "none" ? "requestOptions?: RequestOptions" - : `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions` - const path = promisePath(endpoint.endpoint.path, endpoint.input) - const access = (name: string) => - `input${endpoint.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]` + : `input${inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions` + const path = promisePath(endpoint.endpoint.path, endpoint.input, promiseWildcardInput(endpoint)) + const access = (name: string) => `input${inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]` const part = (source: InputField["source"]) => { const inputs = endpoint.input.filter((field) => field.source === source) return inputs.length === 0 @@ -518,7 +538,7 @@ function renderPromiseClient(groups: ReadonlyArray) { endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`, ].filter((value): value is string => value !== undefined) const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))] - const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }` + const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.successes[0]) ? ", binary: true" : ""} }` if (endpoint.operation.success === "stream") { const success = endpoint.successes[0] if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") { @@ -583,10 +603,67 @@ function structuralType(schema: Schema.Top) { .replaceAll("Schema.Json", "JsonValue") } -function promisePath(path: string, input: ReadonlyArray) { - if (path.includes("*")) throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${path}` }) +function normalizePromiseClientContent(content: string, groups: ReadonlyArray) { + const endpoints = groups.flatMap((group) => group.endpoints) + const usesBinary = endpoints.some((endpoint) => isBinarySchema(endpoint.successes[0])) + const usesWildcard = endpoints.some((endpoint) => promiseWildcardInput(endpoint) !== undefined) + + const sseReady = replaceOne(content, "let next: ReadableStreamReadResult", "let next") + const binaryReady = usesBinary + ? replaceOne( + replaceOne(sseReady, "readonly empty: boolean\n}", "readonly empty: boolean\n readonly binary?: true\n}"), + "if (descriptor.empty) {", + "if (descriptor.binary) return new Uint8Array(await response.arrayBuffer()) as A\n if (descriptor.empty) {", + ) + : sseReady + return usesWildcard + ? replaceOne( + binaryReady, + "function appendQuery(params: URLSearchParams, key: string, value: unknown): void {", + 'function encodePath(value: string): string {\n return value.split("/").map(encodeURIComponent).join("/")\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {', + ) + : binaryReady +} + +function replaceOne(input: string, search: string, replacement: string) { + if (!input.includes(search)) + throw new GenerationError({ reason: `Missing Promise client template marker: ${search}` }) + return input.replace(search, replacement) +} + +function promiseInput(endpoint: Endpoint): ReadonlyArray { + const wildcard = promiseWildcardInput(endpoint) + if (wildcard === undefined) return endpoint.input + return [...endpoint.input, wildcard] +} + +function promiseInputMode(endpoint: Endpoint): Operation["inputMode"] { + const input = promiseInput(endpoint) + if (input.length === 0) return "none" + return input.every((field) => field.optional) ? "optional" : "required" +} + +function promiseWildcardInput(endpoint: Endpoint): PromiseInputField | undefined { + if (!endpoint.endpoint.path.includes("*")) return undefined + if (endpoint.endpoint.path.indexOf("*") !== endpoint.endpoint.path.lastIndexOf("*")) { + throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${endpoint.endpoint.path}` }) + } + if (!endpoint.endpoint.path.endsWith("*")) { + throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${endpoint.endpoint.path}` }) + } + const name = endpoint.input.some((field) => field.name === "path") ? "wildcard" : "path" + return { name, source: "wildcard", optional: false } +} + +function isBinarySchema(schema: Schema.Top) { + return (resolveHttpApiEncoding(schema.ast)?._tag ?? "Json") === "Uint8Array" +} + +function promisePath(path: string, input: ReadonlyArray, wildcard?: PromiseInputField) { const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name)) - const segments = path.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g).filter(Boolean) + const segments = (wildcard === undefined ? path : path.slice(0, -1)) + .split(/(:[A-Za-z_][A-Za-z0-9_]*)/g) + .filter(Boolean) const template = segments .map((segment) => { if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`") @@ -595,7 +672,7 @@ function promisePath(path: string, input: ReadonlyArray) { return `\${encodeURIComponent(input.${name})}` }) .join("") - return `\`${template}\`` + return `\`${template}${wildcard === undefined ? "" : `\${encodePath(input.${wildcard.name})}`}\`` } function uniqueModule(base: string, index: number, modules: ReadonlySet) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 6e076b7ef4..a5a9d75770 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -355,20 +355,8 @@ describe("HttpApiCodegen.generate", () => { ).toThrow("Unsupported Promise success encoding: session.text") expect(() => - emitPromise( - compileContract( - api( - HttpApiEndpoint.get("binary", "/binary", { - success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), - }), - ), - ), - ), - ).toThrow("Unsupported Promise success encoding: session.binary") - - expect(() => - emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*", { success: Schema.String })))), - ).toThrow("Unsupported Promise path wildcard: /file/*") + emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*/tail", { success: Schema.String })))), + ).toThrow("Unsupported Promise path wildcard: /file/*/tail") expect(() => emitPromise( @@ -443,6 +431,41 @@ describe("HttpApiCodegen.generate", () => { } }) + test("executes an emitted binary wildcard GET through fetch", async () => { + const output = emitPromise( + compileContract( + api( + HttpApiEndpoint.get("read", "/file/*", { + query: { token: Schema.optional(Schema.String) }, + success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()), + }), + ), + ), + ) + const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-")) + + try { + await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content))) + const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`) + let request: Request | undefined + const client = generated.OpenCode.make({ + baseUrl: "https://example.com", + fetch: async (input: RequestInfo | URL) => { + request = input instanceof Request ? input : new Request(input) + return new Response(new Uint8Array([1, 2, 3])) + }, + }) + + const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" }) + expect(result).toBeInstanceOf(Uint8Array) + expect(Array.from(result)).toEqual([1, 2, 3]) + expect(request?.method).toBe("GET") + expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy") + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) + test("serializes flattened query, header, and JSON payload inputs", async () => { const output = emitPromise( compileContract( From f928b5be07087e667c2fa479a82bbcb2b4949aae Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:30:40 -0500 Subject: [PATCH 14/27] fix(core): sanitize registered tool names (#34512) --- packages/core/src/tool/application-tools.ts | 3 +-- packages/core/src/tool/registry.ts | 5 ++--- packages/core/src/tool/tool.ts | 3 +++ packages/core/test/application-tools.test.ts | 9 +++------ 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts index 3b46c490cb..5c06541820 100644 --- a/packages/core/src/tool/application-tools.ts +++ b/packages/core/src/tool/application-tools.ts @@ -41,9 +41,8 @@ export const layer = Layer.effect( return Service.of({ register: Effect.fn("ApplicationTools.register")(function* (tools) { - const entries = Object.entries(tools) + const entries = Tool.registrationEntries(tools) if (entries.length === 0) return - yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true }) const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const) yield* state.transform((draft) => { for (const [name, entry] of registrations) draft.set(name, entry) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 8110e29da7..4119306031 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -9,7 +9,7 @@ import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" import { ApplicationTools } from "./application-tools" -import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool" +import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool" import { Tools } from "./tools" import { makeLocationNode } from "../effect/app-node" @@ -83,9 +83,8 @@ const registryLayer = Layer.effect( return Service.of({ register: Effect.fn("ToolRegistry.register")(function* (tools) { - const entries = Object.entries(tools) + const entries = registrationEntries(tools) if (entries.length === 0) return - yield* Effect.forEach(entries, ([name]) => validateName(name), { discard: true }) yield* Effect.uninterruptible( Effect.gen(function* () { const token = {} diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 1d9a82e952..8ee5b76596 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -136,6 +136,9 @@ export const validateName = (name: string) => ? Effect.void : Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` })) +export const registrationEntries = (tools: Readonly>) => + Object.entries(tools).map(([name, tool]) => [name.replace(/[^a-zA-Z0-9_-]/g, "_"), tool] as const) + export const withPermission = , Output extends SchemaType>( tool: Definition, permission: string, diff --git a/packages/core/test/application-tools.test.ts b/packages/core/test/application-tools.test.ts index dac711f3c6..2265e03067 100644 --- a/packages/core/test/application-tools.test.ts +++ b/packages/core/test/application-tools.test.ts @@ -70,17 +70,14 @@ describe("ApplicationTools", () => { }), ) - it.effect("exposes narrow scoped Location registration and validates names", () => + it.effect("exposes narrow scoped Location registration and sanitizes names", () => Effect.gen(function* () { const tools: Tools.Interface = yield* Tools.Service const registry = yield* ToolRegistry.Service const scope = yield* Scope.make() - yield* tools.register({ location_tool: contextual([]) }).pipe(Scope.provide(scope)) - expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool"]) - expect(yield* Effect.flip(tools.register({ "invalid name": contextual([]) }))).toBeInstanceOf( - Tool.RegistrationError, - ) + yield* tools.register({ "location.tool/search": contextual([]) }).pipe(Scope.provide(scope)) + expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool_search"]) yield* Scope.close(scope, Exit.void) expect(yield* toolDefinitions(registry)).toEqual([]) From 684654211558f0f4111a19f109240893d34cd12b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 29 Jun 2026 21:07:01 -0400 Subject: [PATCH 15/27] fix(client): singularize generated api groups (#34534) --- packages/cli/src/tui.ts | 2 +- packages/client/src/contract.ts | 33 +- .../client/src/generated-effect/client.ts | 34 +- packages/client/src/generated/client.ts | 540 +++--- packages/client/src/generated/types.ts | 1572 +++++++++++++++-- packages/client/src/index.ts | 2 +- packages/client/test/effect.test.ts | 44 +- packages/client/test/promise.test.ts | 128 +- .../tui/src/component/dialog-integration.tsx | 18 +- .../tui/src/component/dialog-move-session.tsx | 6 +- .../tui/src/component/dialog-session-list.tsx | 2 +- .../src/component/dialog-session-rename.tsx | 2 +- packages/tui/src/component/dialog-tag.tsx | 2 +- .../tui/src/component/prompt/autocomplete.tsx | 2 +- packages/tui/src/component/prompt/index.tsx | 12 +- packages/tui/src/component/prompt/move.tsx | 2 +- packages/tui/src/context/data.tsx | 34 +- .../tui/src/routes/session/dialog-message.tsx | 2 +- packages/tui/src/routes/session/index.tsx | 19 +- .../tui/src/routes/session/permission.tsx | 8 +- packages/tui/src/routes/session/question.tsx | 6 +- 21 files changed, 1913 insertions(+), 557 deletions(-) diff --git a/packages/cli/src/tui.ts b/packages/cli/src/tui.ts index 053b99f433..2c2d3fcc5c 100644 --- a/packages/cli/src/tui.ts +++ b/packages/cli/src/tui.ts @@ -14,7 +14,7 @@ export function runTui(transport: Transport, reload?: () => Promise) return Effect.gen(function* () { const options = { baseUrl: transport.url, headers: transport.headers } const api = OpenCode.make(options) - const directory = yield* Effect.tryPromise(() => api.files.list({ location: { directory: process.cwd() } })).pipe( + const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe( Effect.map((response) => response.location.directory), Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)), diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 32f85abb97..2cc838c3dc 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -19,24 +19,25 @@ export const ClientApi = makeDefaultApi({ export const groupNames = { "server.health": "health", "server.location": "location", - "server.agent": "agents", - "server.session": "sessions", - "server.message": "messages", - "server.model": "models", + "server.agent": "agent", + "server.session": "session", + "server.message": "message", + "server.model": "model", "server.generate": "generate", - "server.provider": "providers", - "server.integration": "integrations", - "server.credential": "credentials", - "server.permission": "permissions", - "server.fs": "files", - "server.command": "commands", - "server.skill": "skills", - "server.event": "events", - "server.pty": "ptys", - "server.question": "questions", - "server.reference": "references", + "server.provider": "provider", + "server.integration": "integration", + "server.credential": "credential", + "server.permission": "permission", + "server.fs": "file", + "server.command": "command", + "server.skill": "skill", + "server.event": "event", + "server.pty": "pty", + "server.shell": "shell", + "server.question": "question", + "server.reference": "reference", "server.project": "project", - "server.projectCopy": "projectCopies", + "server.projectCopy": "projectCopy", } as const export const endpointNames = { diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 30047f4a04..59f12e225d 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -805,25 +805,25 @@ const adaptGroup20 = (raw: RawClient["server.projectCopy"]) => ({ const adaptClient = (raw: RawClient) => ({ health: adaptGroup0(raw["server.health"]), location: adaptGroup1(raw["server.location"]), - agents: adaptGroup2(raw["server.agent"]), - sessions: adaptGroup3(raw["server.session"]), - messages: adaptGroup4(raw["server.message"]), - models: adaptGroup5(raw["server.model"]), + agent: adaptGroup2(raw["server.agent"]), + session: adaptGroup3(raw["server.session"]), + message: adaptGroup4(raw["server.message"]), + model: adaptGroup5(raw["server.model"]), generate: adaptGroup6(raw["server.generate"]), - providers: adaptGroup7(raw["server.provider"]), - integrations: adaptGroup8(raw["server.integration"]), - credentials: adaptGroup9(raw["server.credential"]), + provider: adaptGroup7(raw["server.provider"]), + integration: adaptGroup8(raw["server.integration"]), + credential: adaptGroup9(raw["server.credential"]), project: adaptGroup10(raw["server.project"]), - permissions: adaptGroup11(raw["server.permission"]), - files: adaptGroup12(raw["server.fs"]), - commands: adaptGroup13(raw["server.command"]), - skills: adaptGroup14(raw["server.skill"]), - events: adaptGroup15(raw["server.event"]), - ptys: adaptGroup16(raw["server.pty"]), - "server.shell": adaptGroup17(raw["server.shell"]), - questions: adaptGroup18(raw["server.question"]), - references: adaptGroup19(raw["server.reference"]), - projectCopies: adaptGroup20(raw["server.projectCopy"]), + permission: adaptGroup11(raw["server.permission"]), + file: adaptGroup12(raw["server.fs"]), + command: adaptGroup13(raw["server.command"]), + skill: adaptGroup14(raw["server.skill"]), + event: adaptGroup15(raw["server.event"]), + pty: adaptGroup16(raw["server.pty"]), + shell: adaptGroup17(raw["server.shell"]), + question: adaptGroup18(raw["server.question"]), + reference: adaptGroup19(raw["server.reference"]), + projectCopy: adaptGroup20(raw["server.projectCopy"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 8a830f49b7..a3402e0c4c 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -2,138 +2,138 @@ import type { HealthGetOutput, LocationGetInput, LocationGetOutput, - AgentsListInput, - AgentsListOutput, - SessionsListInput, - SessionsListOutput, - SessionsCreateInput, - SessionsCreateOutput, - SessionsActiveOutput, - SessionsGetInput, - SessionsGetOutput, - SessionsForkInput, - SessionsForkOutput, - SessionsSwitchAgentInput, - SessionsSwitchAgentOutput, - SessionsSwitchModelInput, - SessionsSwitchModelOutput, - SessionsRenameInput, - SessionsRenameOutput, - SessionsPromptInput, - SessionsPromptOutput, - SessionsCompactInput, - SessionsCompactOutput, - SessionsWaitInput, - SessionsWaitOutput, - SessionsStageInput, - SessionsStageOutput, - SessionsClearInput, - SessionsClearOutput, - SessionsCommitInput, - SessionsCommitOutput, - SessionsContextInput, - SessionsContextOutput, - SessionsHistoryInput, - SessionsHistoryOutput, - SessionsEventsInput, - SessionsEventsOutput, - SessionsInterruptInput, - SessionsInterruptOutput, - SessionsMessageInput, - SessionsMessageOutput, - MessagesListInput, - MessagesListOutput, - ModelsListInput, - ModelsListOutput, + AgentListInput, + AgentListOutput, + SessionListInput, + SessionListOutput, + SessionCreateInput, + SessionCreateOutput, + SessionActiveOutput, + SessionGetInput, + SessionGetOutput, + SessionForkInput, + SessionForkOutput, + SessionSwitchAgentInput, + SessionSwitchAgentOutput, + SessionSwitchModelInput, + SessionSwitchModelOutput, + SessionRenameInput, + SessionRenameOutput, + SessionPromptInput, + SessionPromptOutput, + SessionCompactInput, + SessionCompactOutput, + SessionWaitInput, + SessionWaitOutput, + SessionStageInput, + SessionStageOutput, + SessionClearInput, + SessionClearOutput, + SessionCommitInput, + SessionCommitOutput, + SessionContextInput, + SessionContextOutput, + SessionHistoryInput, + SessionHistoryOutput, + SessionEventsInput, + SessionEventsOutput, + SessionInterruptInput, + SessionInterruptOutput, + SessionMessageInput, + SessionMessageOutput, + MessageListInput, + MessageListOutput, + ModelListInput, + ModelListOutput, GenerateTextInput, GenerateTextOutput, - ProvidersListInput, - ProvidersListOutput, - ProvidersGetInput, - ProvidersGetOutput, - IntegrationsListInput, - IntegrationsListOutput, - IntegrationsGetInput, - IntegrationsGetOutput, - IntegrationsConnectKeyInput, - IntegrationsConnectKeyOutput, - IntegrationsConnectOauthInput, - IntegrationsConnectOauthOutput, - IntegrationsAttemptStatusInput, - IntegrationsAttemptStatusOutput, - IntegrationsAttemptCompleteInput, - IntegrationsAttemptCompleteOutput, - IntegrationsAttemptCancelInput, - IntegrationsAttemptCancelOutput, - CredentialsUpdateInput, - CredentialsUpdateOutput, - CredentialsRemoveInput, - CredentialsRemoveOutput, + ProviderListInput, + ProviderListOutput, + ProviderGetInput, + ProviderGetOutput, + IntegrationListInput, + IntegrationListOutput, + IntegrationGetInput, + IntegrationGetOutput, + IntegrationConnectKeyInput, + IntegrationConnectKeyOutput, + IntegrationConnectOauthInput, + IntegrationConnectOauthOutput, + IntegrationAttemptStatusInput, + IntegrationAttemptStatusOutput, + IntegrationAttemptCompleteInput, + IntegrationAttemptCompleteOutput, + IntegrationAttemptCancelInput, + IntegrationAttemptCancelOutput, + CredentialUpdateInput, + CredentialUpdateOutput, + CredentialRemoveInput, + CredentialRemoveOutput, ProjectCurrentInput, ProjectCurrentOutput, ProjectDirectoriesInput, ProjectDirectoriesOutput, - PermissionsListRequestsInput, - PermissionsListRequestsOutput, - PermissionsListSavedInput, - PermissionsListSavedOutput, - PermissionsRemoveSavedInput, - PermissionsRemoveSavedOutput, - PermissionsCreateInput, - PermissionsCreateOutput, - PermissionsListInput, - PermissionsListOutput, - PermissionsGetInput, - PermissionsGetOutput, - PermissionsReplyInput, - PermissionsReplyOutput, - FilesReadInput, - FilesReadOutput, - FilesListInput, - FilesListOutput, - FilesFindInput, - FilesFindOutput, - CommandsListInput, - CommandsListOutput, - SkillsListInput, - SkillsListOutput, - EventsSubscribeOutput, - PtysListInput, - PtysListOutput, - PtysCreateInput, - PtysCreateOutput, - PtysGetInput, - PtysGetOutput, - PtysUpdateInput, - PtysUpdateOutput, - PtysRemoveInput, - PtysRemoveOutput, - ServerShellListInput, - ServerShellListOutput, - ServerShellCreateInput, - ServerShellCreateOutput, - ServerShellGetInput, - ServerShellGetOutput, - ServerShellOutputInput, - ServerShellOutputOutput, - ServerShellRemoveInput, - ServerShellRemoveOutput, - QuestionsListRequestsInput, - QuestionsListRequestsOutput, - QuestionsListInput, - QuestionsListOutput, - QuestionsReplyInput, - QuestionsReplyOutput, - QuestionsRejectInput, - QuestionsRejectOutput, - ReferencesListInput, - ReferencesListOutput, - ProjectCopiesCreateInput, - ProjectCopiesCreateOutput, - ProjectCopiesRemoveInput, - ProjectCopiesRemoveOutput, - ProjectCopiesRefreshInput, - ProjectCopiesRefreshOutput, + PermissionListRequestsInput, + PermissionListRequestsOutput, + PermissionListSavedInput, + PermissionListSavedOutput, + PermissionRemoveSavedInput, + PermissionRemoveSavedOutput, + PermissionCreateInput, + PermissionCreateOutput, + PermissionListInput, + PermissionListOutput, + PermissionGetInput, + PermissionGetOutput, + PermissionReplyInput, + PermissionReplyOutput, + FileReadInput, + FileReadOutput, + FileListInput, + FileListOutput, + FileFindInput, + FileFindOutput, + CommandListInput, + CommandListOutput, + SkillListInput, + SkillListOutput, + EventSubscribeOutput, + PtyListInput, + PtyListOutput, + PtyCreateInput, + PtyCreateOutput, + PtyGetInput, + PtyGetOutput, + PtyUpdateInput, + PtyUpdateOutput, + PtyRemoveInput, + PtyRemoveOutput, + ShellListInput, + ShellListOutput, + ShellCreateInput, + ShellCreateOutput, + ShellGetInput, + ShellGetOutput, + ShellOutputInput, + ShellOutputOutput, + ShellRemoveInput, + ShellRemoveOutput, + QuestionListRequestsInput, + QuestionListRequestsOutput, + QuestionListInput, + QuestionListOutput, + QuestionReplyInput, + QuestionReplyOutput, + QuestionRejectInput, + QuestionRejectOutput, + ReferenceListInput, + ReferenceListOutput, + ProjectCopyCreateInput, + ProjectCopyCreateOutput, + ProjectCopyRemoveInput, + ProjectCopyRemoveOutput, + ProjectCopyRefreshInput, + ProjectCopyRefreshOutput, } from "./types" import { ClientError } from "./client-error" @@ -292,9 +292,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - agents: { - list: (input?: AgentsListInput, requestOptions?: RequestOptions) => - request( + agent: { + list: (input?: AgentListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/agent`, @@ -306,9 +306,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - sessions: { - list: (input?: SessionsListInput, requestOptions?: RequestOptions) => - request( + session: { + list: (input?: SessionListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/session`, @@ -328,8 +328,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionsCreateOutput }>( + create: (input?: SessionCreateInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionCreateOutput }>( { method: "POST", path: `/api/session`, @@ -346,7 +346,7 @@ export function make(options: ClientOptions) { requestOptions, ).then((value) => value.data), active: (requestOptions?: RequestOptions) => - request<{ readonly data: SessionsActiveOutput }>( + request<{ readonly data: SessionActiveOutput }>( { method: "GET", path: `/api/session/active`, @@ -356,8 +356,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - get: (input: SessionsGetInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionsGetOutput }>( + get: (input: SessionGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionGetOutput }>( { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}`, @@ -367,8 +367,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - fork: (input: SessionsForkInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionsForkOutput }>( + fork: (input: SessionForkInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionForkOutput }>( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`, @@ -379,8 +379,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) => - request( + switchAgent: (input: SessionSwitchAgentInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`, @@ -391,8 +391,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) => - request( + switchModel: (input: SessionSwitchModelInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/model`, @@ -403,8 +403,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - rename: (input: SessionsRenameInput, requestOptions?: RequestOptions) => - request( + rename: (input: SessionRenameInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/rename`, @@ -415,8 +415,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionsPromptOutput }>( + prompt: (input: SessionPromptInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionPromptOutput }>( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`, @@ -427,8 +427,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) => - request( + compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, @@ -438,8 +438,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) => - request( + wait: (input: SessionWaitInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`, @@ -449,8 +449,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - stage: (input: SessionsStageInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionsStageOutput }>( + stage: (input: SessionStageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionStageOutput }>( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, @@ -461,8 +461,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - clear: (input: SessionsClearInput, requestOptions?: RequestOptions) => - request( + clear: (input: SessionClearInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`, @@ -472,8 +472,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - commit: (input: SessionsCommitInput, requestOptions?: RequestOptions) => - request( + commit: (input: SessionCommitInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, @@ -483,8 +483,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - context: (input: SessionsContextInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionsContextOutput }>( + context: (input: SessionContextInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionContextOutput }>( { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/context`, @@ -494,8 +494,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) => - request( + history: (input: SessionHistoryInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/history`, @@ -506,8 +506,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable => - sse( + events: (input: SessionEventsInput, requestOptions?: RequestOptions): AsyncIterable => + sse( { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/event`, @@ -518,8 +518,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - interrupt: (input: SessionsInterruptInput, requestOptions?: RequestOptions) => - request( + interrupt: (input: SessionInterruptInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`, @@ -529,8 +529,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - message: (input: SessionsMessageInput, requestOptions?: RequestOptions) => - request<{ readonly data: SessionsMessageOutput }>( + message: (input: SessionMessageInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionMessageOutput }>( { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`, @@ -541,9 +541,9 @@ export function make(options: ClientOptions) { requestOptions, ).then((value) => value.data), }, - messages: { - list: (input: MessagesListInput, requestOptions?: RequestOptions) => - request( + message: { + list: (input: MessageListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/message`, @@ -555,9 +555,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - models: { - list: (input?: ModelsListInput, requestOptions?: RequestOptions) => - request( + model: { + list: (input?: ModelListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/model`, @@ -584,9 +584,9 @@ export function make(options: ClientOptions) { requestOptions, ).then((value) => value.data), }, - providers: { - list: (input?: ProvidersListInput, requestOptions?: RequestOptions) => - request( + provider: { + list: (input?: ProviderListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/provider`, @@ -597,8 +597,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - get: (input: ProvidersGetInput, requestOptions?: RequestOptions) => - request( + get: (input: ProviderGetInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/provider/${encodeURIComponent(input.providerID)}`, @@ -610,9 +610,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - integrations: { - list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) => - request( + integration: { + list: (input?: IntegrationListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/integration`, @@ -623,8 +623,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - get: (input: IntegrationsGetInput, requestOptions?: RequestOptions) => - request( + get: (input: IntegrationGetInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/integration/${encodeURIComponent(input.integrationID)}`, @@ -635,8 +635,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - connectKey: (input: IntegrationsConnectKeyInput, requestOptions?: RequestOptions) => - request( + connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`, @@ -648,8 +648,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - connectOauth: (input: IntegrationsConnectOauthInput, requestOptions?: RequestOptions) => - request( + connectOauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`, @@ -661,8 +661,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - attemptStatus: (input: IntegrationsAttemptStatusInput, requestOptions?: RequestOptions) => - request( + attemptStatus: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, @@ -673,8 +673,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - attemptComplete: (input: IntegrationsAttemptCompleteInput, requestOptions?: RequestOptions) => - request( + attemptComplete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`, @@ -686,8 +686,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - attemptCancel: (input: IntegrationsAttemptCancelInput, requestOptions?: RequestOptions) => - request( + attemptCancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) => + request( { method: "DELETE", path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`, @@ -699,9 +699,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - credentials: { - update: (input: CredentialsUpdateInput, requestOptions?: RequestOptions) => - request( + credential: { + update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => + request( { method: "PATCH", path: `/api/credential/${encodeURIComponent(input.credentialID)}`, @@ -713,8 +713,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - remove: (input: CredentialsRemoveInput, requestOptions?: RequestOptions) => - request( + remove: (input: CredentialRemoveInput, requestOptions?: RequestOptions) => + request( { method: "DELETE", path: `/api/credential/${encodeURIComponent(input.credentialID)}`, @@ -752,9 +752,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - permissions: { - listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) => - request( + permission: { + listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/permission/request`, @@ -765,8 +765,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - listSaved: (input?: PermissionsListSavedInput, requestOptions?: RequestOptions) => - request<{ readonly data: PermissionsListSavedOutput }>( + listSaved: (input?: PermissionListSavedInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionListSavedOutput }>( { method: "GET", path: `/api/permission/saved`, @@ -777,8 +777,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - removeSaved: (input: PermissionsRemoveSavedInput, requestOptions?: RequestOptions) => - request( + removeSaved: (input: PermissionRemoveSavedInput, requestOptions?: RequestOptions) => + request( { method: "DELETE", path: `/api/permission/saved/${encodeURIComponent(input.id)}`, @@ -788,8 +788,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - create: (input: PermissionsCreateInput, requestOptions?: RequestOptions) => - request<{ readonly data: PermissionsCreateOutput }>( + create: (input: PermissionCreateInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionCreateOutput }>( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`, @@ -808,8 +808,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - list: (input: PermissionsListInput, requestOptions?: RequestOptions) => - request<{ readonly data: PermissionsListOutput }>( + list: (input: PermissionListInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionListOutput }>( { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`, @@ -819,8 +819,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - get: (input: PermissionsGetInput, requestOptions?: RequestOptions) => - request<{ readonly data: PermissionsGetOutput }>( + get: (input: PermissionGetInput, requestOptions?: RequestOptions) => + request<{ readonly data: PermissionGetOutput }>( { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`, @@ -830,8 +830,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - reply: (input: PermissionsReplyInput, requestOptions?: RequestOptions) => - request( + reply: (input: PermissionReplyInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`, @@ -843,9 +843,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - files: { - read: (input: FilesReadInput, requestOptions?: RequestOptions) => - request( + file: { + read: (input: FileReadInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/fs/read/${encodePath(input.path)}`, @@ -857,8 +857,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - list: (input?: FilesListInput, requestOptions?: RequestOptions) => - request( + list: (input?: FileListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/fs/list`, @@ -869,8 +869,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - find: (input: FilesFindInput, requestOptions?: RequestOptions) => - request( + find: (input: FileFindInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/fs/find`, @@ -882,9 +882,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - commands: { - list: (input?: CommandsListInput, requestOptions?: RequestOptions) => - request( + command: { + list: (input?: CommandListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/command`, @@ -896,9 +896,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - skills: { - list: (input?: SkillsListInput, requestOptions?: RequestOptions) => - request( + skill: { + list: (input?: SkillListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/skill`, @@ -910,16 +910,16 @@ export function make(options: ClientOptions) { requestOptions, ), }, - events: { - subscribe: (requestOptions?: RequestOptions): AsyncIterable => - sse( + event: { + subscribe: (requestOptions?: RequestOptions): AsyncIterable => + sse( { method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, requestOptions, ), }, - ptys: { - list: (input?: PtysListInput, requestOptions?: RequestOptions) => - request( + pty: { + list: (input?: PtyListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/pty`, @@ -930,8 +930,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - create: (input?: PtysCreateInput, requestOptions?: RequestOptions) => - request( + create: (input?: PtyCreateInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/pty`, @@ -949,8 +949,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - get: (input: PtysGetInput, requestOptions?: RequestOptions) => - request( + get: (input: PtyGetInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/pty/${encodeURIComponent(input.ptyID)}`, @@ -961,8 +961,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - update: (input: PtysUpdateInput, requestOptions?: RequestOptions) => - request( + update: (input: PtyUpdateInput, requestOptions?: RequestOptions) => + request( { method: "PUT", path: `/api/pty/${encodeURIComponent(input.ptyID)}`, @@ -974,8 +974,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - remove: (input: PtysRemoveInput, requestOptions?: RequestOptions) => - request( + remove: (input: PtyRemoveInput, requestOptions?: RequestOptions) => + request( { method: "DELETE", path: `/api/pty/${encodeURIComponent(input.ptyID)}`, @@ -987,9 +987,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - "server.shell": { - list: (input?: ServerShellListInput, requestOptions?: RequestOptions) => - request( + shell: { + list: (input?: ShellListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/shell`, @@ -1000,8 +1000,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - create: (input: ServerShellCreateInput, requestOptions?: RequestOptions) => - request( + create: (input: ShellCreateInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/shell`, @@ -1018,8 +1018,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - get: (input: ServerShellGetInput, requestOptions?: RequestOptions) => - request( + get: (input: ShellGetInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/shell/${encodeURIComponent(input.id)}`, @@ -1030,8 +1030,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - output: (input: ServerShellOutputInput, requestOptions?: RequestOptions) => - request( + output: (input: ShellOutputInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/shell/${encodeURIComponent(input.id)}/output`, @@ -1042,8 +1042,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - remove: (input: ServerShellRemoveInput, requestOptions?: RequestOptions) => - request( + remove: (input: ShellRemoveInput, requestOptions?: RequestOptions) => + request( { method: "DELETE", path: `/api/shell/${encodeURIComponent(input.id)}`, @@ -1055,9 +1055,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - questions: { - listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) => - request( + question: { + listRequests: (input?: QuestionListRequestsInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/question/request`, @@ -1068,8 +1068,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - list: (input: QuestionsListInput, requestOptions?: RequestOptions) => - request<{ readonly data: QuestionsListOutput }>( + list: (input: QuestionListInput, requestOptions?: RequestOptions) => + request<{ readonly data: QuestionListOutput }>( { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/question`, @@ -1079,8 +1079,8 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), - reply: (input: QuestionsReplyInput, requestOptions?: RequestOptions) => - request( + reply: (input: QuestionReplyInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`, @@ -1091,8 +1091,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - reject: (input: QuestionsRejectInput, requestOptions?: RequestOptions) => - request( + reject: (input: QuestionRejectInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`, @@ -1103,9 +1103,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - references: { - list: (input?: ReferencesListInput, requestOptions?: RequestOptions) => - request( + reference: { + list: (input?: ReferenceListInput, requestOptions?: RequestOptions) => + request( { method: "GET", path: `/api/reference`, @@ -1117,9 +1117,9 @@ export function make(options: ClientOptions) { requestOptions, ), }, - projectCopies: { - create: (input: ProjectCopiesCreateInput, requestOptions?: RequestOptions) => - request( + projectCopy: { + create: (input: ProjectCopyCreateInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`, @@ -1131,8 +1131,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - remove: (input: ProjectCopiesRemoveInput, requestOptions?: RequestOptions) => - request( + remove: (input: ProjectCopyRemoveInput, requestOptions?: RequestOptions) => + request( { method: "DELETE", path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`, @@ -1144,8 +1144,8 @@ export function make(options: ClientOptions) { }, requestOptions, ), - refresh: (input: ProjectCopiesRefreshInput, requestOptions?: RequestOptions) => - request( + refresh: (input: ProjectCopyRefreshInput, requestOptions?: RequestOptions) => + request( { method: "POST", path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`, diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index a375bc1e7c..d09c3bdcfd 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -127,13 +127,13 @@ export type LocationGetOutput = { readonly project: { readonly id: string; readonly directory: string } } -export type AgentsListInput = { +export type AgentListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type AgentsListOutput = { +export type AgentListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -160,7 +160,7 @@ export type AgentsListOutput = { }> } -export type SessionsListInput = { +export type SessionListInput = { readonly workspace?: { readonly workspace?: string | undefined readonly limit?: number | undefined @@ -243,7 +243,7 @@ export type SessionsListInput = { }["cursor"] } -export type SessionsListOutput = { +export type SessionListOutput = { readonly data: ReadonlyArray<{ readonly id: string readonly parentID?: string @@ -278,7 +278,7 @@ export type SessionsListOutput = { readonly cursor: { readonly previous?: string | null; readonly next?: string | null } } -export type SessionsCreateInput = { +export type SessionCreateInput = { readonly id?: { readonly id?: string | null readonly agent?: string | null @@ -305,7 +305,7 @@ export type SessionsCreateInput = { }["location"] } -export type SessionsCreateOutput = { +export type SessionCreateOutput = { readonly data: { readonly id: string readonly parentID?: string @@ -339,11 +339,11 @@ export type SessionsCreateOutput = { } }["data"] -export type SessionsActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"] +export type SessionActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"] -export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionsGetOutput = { +export type SessionGetOutput = { readonly data: { readonly id: string readonly parentID?: string @@ -377,12 +377,12 @@ export type SessionsGetOutput = { } }["data"] -export type SessionsForkInput = { +export type SessionForkInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly messageID?: { readonly messageID?: string | undefined }["messageID"] } -export type SessionsForkOutput = { +export type SessionForkOutput = { readonly data: { readonly id: string readonly parentID?: string @@ -416,30 +416,30 @@ export type SessionsForkOutput = { } }["data"] -export type SessionsSwitchAgentInput = { +export type SessionSwitchAgentInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly agent: { readonly agent: string }["agent"] } -export type SessionsSwitchAgentOutput = void +export type SessionSwitchAgentOutput = void -export type SessionsSwitchModelInput = { +export type SessionSwitchModelInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly model: { readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } }["model"] } -export type SessionsSwitchModelOutput = void +export type SessionSwitchModelOutput = void -export type SessionsRenameInput = { +export type SessionRenameInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly title: { readonly title: string }["title"] } -export type SessionsRenameOutput = void +export type SessionRenameOutput = void -export type SessionsPromptInput = { +export type SessionPromptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { readonly id?: string | null @@ -515,7 +515,7 @@ export type SessionsPromptInput = { }["resume"] } -export type SessionsPromptOutput = { +export type SessionPromptOutput = { readonly data: { readonly admittedSeq: number readonly id: string @@ -540,21 +540,21 @@ export type SessionsPromptOutput = { } }["data"] -export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionsCompactOutput = void +export type SessionCompactOutput = void -export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionsWaitOutput = void +export type SessionWaitOutput = void -export type SessionsStageInput = { +export type SessionStageInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"] readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"] } -export type SessionsStageOutput = { +export type SessionStageOutput = { readonly data: { readonly messageID: string readonly partID?: string @@ -570,17 +570,17 @@ export type SessionsStageOutput = { } }["data"] -export type SessionsClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type SessionClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionsClearOutput = void +export type SessionClearOutput = void -export type SessionsCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type SessionCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionsCommitOutput = void +export type SessionCommitOutput = void -export type SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type SessionContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionsContextOutput = { +export type SessionContextOutput = { readonly data: ReadonlyArray< | { readonly id: string @@ -734,13 +734,13 @@ export type SessionsContextOutput = { > }["data"] -export type SessionsHistoryInput = { +export type SessionHistoryInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"] readonly after?: { readonly limit?: number | undefined; readonly after?: number | undefined }["after"] } -export type SessionsHistoryOutput = { +export type SessionHistoryOutput = { readonly data: ReadonlyArray< | { readonly id: string @@ -1215,12 +1215,12 @@ export type SessionsHistoryOutput = { readonly hasMore: boolean } -export type SessionsEventsInput = { +export type SessionEventsInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly after?: { readonly after?: number | undefined }["after"] } -export type SessionsEventsOutput = +export type SessionEventsOutput = | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } @@ -1691,16 +1691,16 @@ export type SessionsEventsOutput = readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } } -export type SessionsInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type SessionInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type SessionsInterruptOutput = void +export type SessionInterruptOutput = void -export type SessionsMessageInput = { +export type SessionMessageInput = { readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"] readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"] } -export type SessionsMessageOutput = { +export type SessionMessageOutput = { readonly data: | { readonly id: string @@ -1853,7 +1853,7 @@ export type SessionsMessageOutput = { } }["data"] -export type MessagesListInput = { +export type MessageListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly limit?: { readonly limit?: number | undefined @@ -1872,7 +1872,7 @@ export type MessagesListInput = { }["cursor"] } -export type MessagesListOutput = { +export type MessageListOutput = { readonly data: ReadonlyArray< | { readonly id: string @@ -2027,13 +2027,13 @@ export type MessagesListOutput = { readonly cursor: { readonly previous?: string | null; readonly next?: string | null } } -export type ModelsListInput = { +export type ModelListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type ModelsListOutput = { +export type ModelListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2102,13 +2102,13 @@ export type GenerateTextInput = { export type GenerateTextOutput = { readonly data: { readonly text: string } }["data"] -export type ProvidersListInput = { +export type ProviderListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type ProvidersListOutput = { +export type ProviderListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2134,14 +2134,14 @@ export type ProvidersListOutput = { }> } -export type ProvidersGetInput = { +export type ProviderGetInput = { readonly providerID: { readonly providerID: string }["providerID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type ProvidersGetOutput = { +export type ProviderGetOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2167,13 +2167,13 @@ export type ProvidersGetOutput = { } } -export type IntegrationsListInput = { +export type IntegrationListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type IntegrationsListOutput = { +export type IntegrationListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2218,14 +2218,14 @@ export type IntegrationsListOutput = { }> } -export type IntegrationsGetInput = { +export type IntegrationGetInput = { readonly integrationID: { readonly integrationID: string }["integrationID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type IntegrationsGetOutput = { +export type IntegrationGetOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2270,7 +2270,7 @@ export type IntegrationsGetOutput = { } | null } -export type IntegrationsConnectKeyInput = { +export type IntegrationConnectKeyInput = { readonly integrationID: { readonly integrationID: string }["integrationID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -2279,9 +2279,9 @@ export type IntegrationsConnectKeyInput = { readonly label?: { readonly key: string; readonly label?: string | undefined }["label"] } -export type IntegrationsConnectKeyOutput = void +export type IntegrationConnectKeyOutput = void -export type IntegrationsConnectOauthInput = { +export type IntegrationConnectOauthInput = { readonly integrationID: { readonly integrationID: string }["integrationID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -2303,7 +2303,7 @@ export type IntegrationsConnectOauthInput = { }["label"] } -export type IntegrationsConnectOauthOutput = { +export type IntegrationConnectOauthOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2321,14 +2321,14 @@ export type IntegrationsConnectOauthOutput = { } } -export type IntegrationsAttemptStatusInput = { +export type IntegrationAttemptStatusInput = { readonly attemptID: { readonly attemptID: string }["attemptID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type IntegrationsAttemptStatusOutput = { +export type IntegrationAttemptStatusOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2366,7 +2366,7 @@ export type IntegrationsAttemptStatusOutput = { } } -export type IntegrationsAttemptCompleteInput = { +export type IntegrationAttemptCompleteInput = { readonly attemptID: { readonly attemptID: string }["attemptID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -2374,18 +2374,18 @@ export type IntegrationsAttemptCompleteInput = { readonly code?: { readonly code?: string | undefined }["code"] } -export type IntegrationsAttemptCompleteOutput = void +export type IntegrationAttemptCompleteOutput = void -export type IntegrationsAttemptCancelInput = { +export type IntegrationAttemptCancelInput = { readonly attemptID: { readonly attemptID: string }["attemptID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type IntegrationsAttemptCancelOutput = void +export type IntegrationAttemptCancelOutput = void -export type CredentialsUpdateInput = { +export type CredentialUpdateInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -2393,16 +2393,16 @@ export type CredentialsUpdateInput = { readonly label: { readonly label: string }["label"] } -export type CredentialsUpdateOutput = void +export type CredentialUpdateOutput = void -export type CredentialsRemoveInput = { +export type CredentialRemoveInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type CredentialsRemoveOutput = void +export type CredentialRemoveOutput = void export type ProjectCurrentInput = { readonly location?: { @@ -2421,13 +2421,13 @@ export type ProjectDirectoriesInput = { export type ProjectDirectoriesOutput = ReadonlyArray<{ readonly directory: string; readonly strategy?: string }> -export type PermissionsListRequestsInput = { +export type PermissionListRequestsInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type PermissionsListRequestsOutput = { +export type PermissionListRequestsOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2444,11 +2444,9 @@ export type PermissionsListRequestsOutput = { }> } -export type PermissionsListSavedInput = { - readonly projectID?: { readonly projectID?: string | undefined }["projectID"] -} +export type PermissionListSavedInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] } -export type PermissionsListSavedOutput = { +export type PermissionListSavedOutput = { readonly data: ReadonlyArray<{ readonly id: string readonly projectID: string @@ -2457,11 +2455,11 @@ export type PermissionsListSavedOutput = { }> }["data"] -export type PermissionsRemoveSavedInput = { readonly id: { readonly id: string }["id"] } +export type PermissionRemoveSavedInput = { readonly id: { readonly id: string }["id"] } -export type PermissionsRemoveSavedOutput = void +export type PermissionRemoveSavedOutput = void -export type PermissionsCreateInput = { +export type PermissionCreateInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { readonly id?: string | null @@ -2528,13 +2526,13 @@ export type PermissionsCreateInput = { }["agent"] } -export type PermissionsCreateOutput = { +export type PermissionCreateOutput = { readonly data: { readonly id: string; readonly effect: "allow" | "deny" | "ask" } }["data"] -export type PermissionsListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type PermissionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type PermissionsListOutput = { +export type PermissionListOutput = { readonly data: ReadonlyArray<{ readonly id: string readonly sessionID: string @@ -2546,12 +2544,12 @@ export type PermissionsListOutput = { }> }["data"] -export type PermissionsGetInput = { +export type PermissionGetInput = { readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] } -export type PermissionsGetOutput = { +export type PermissionGetOutput = { readonly data: { readonly id: string readonly sessionID: string @@ -2563,25 +2561,25 @@ export type PermissionsGetOutput = { } }["data"] -export type PermissionsReplyInput = { +export type PermissionReplyInput = { readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] readonly reply: { readonly reply: "once" | "always" | "reject"; readonly message?: string | undefined }["reply"] readonly message?: { readonly reply: "once" | "always" | "reject"; readonly message?: string | undefined }["message"] } -export type PermissionsReplyOutput = void +export type PermissionReplyOutput = void -export type FilesReadInput = { +export type FileReadInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] readonly path: string } -export type FilesReadOutput = globalThis.Uint8Array +export type FileReadOutput = globalThis.Uint8Array -export type FilesListInput = { +export type FileListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly path?: string | undefined @@ -2592,7 +2590,7 @@ export type FilesListInput = { }["path"] } -export type FilesListOutput = { +export type FileListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2601,7 +2599,7 @@ export type FilesListOutput = { readonly data: ReadonlyArray<{ readonly path: string; readonly type: "file" | "directory" }> } -export type FilesFindInput = { +export type FileFindInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined readonly query: string @@ -2628,7 +2626,7 @@ export type FilesFindInput = { }["limit"] } -export type FilesFindOutput = { +export type FileFindOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2637,13 +2635,13 @@ export type FilesFindOutput = { readonly data: ReadonlyArray<{ readonly path: string; readonly type: "file" | "directory" }> } -export type CommandsListInput = { +export type CommandListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type CommandsListOutput = { +export type CommandListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2659,13 +2657,13 @@ export type CommandsListOutput = { }> } -export type SkillsListInput = { +export type SkillListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type SkillsListOutput = { +export type SkillListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2680,15 +2678,1333 @@ export type SkillsListOutput = { }> } -export type EventsSubscribeOutput = OpenCodeEventEncoded +export type EventSubscribeOutput = + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "models-dev.refreshed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: {} + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "integration.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: {} + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "integration.connection.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly integrationID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "catalog.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: {} + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.created" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly info: { + readonly id: string + readonly slug: string + readonly projectID: string + readonly workspaceID?: string + readonly directory: string + readonly path?: string + readonly parentID?: string + readonly summary?: { + readonly additions: number + readonly deletions: number + readonly files: number + readonly diffs?: ReadonlyArray<{ + readonly file?: string + readonly patch?: string + readonly additions: number + readonly deletions: number + readonly status?: "added" | "deleted" | "modified" + }> + } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly share?: { readonly url: string } + readonly title: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly version: string + readonly metadata?: { readonly [x: string]: any } + readonly time: { + readonly created: number + readonly updated: number + readonly compacting?: number + readonly archived?: number + } + readonly permission?: ReadonlyArray<{ + readonly permission: string + readonly pattern: string + readonly action: "allow" | "deny" | "ask" + }> + readonly revert?: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly info: { + readonly id: string + readonly slug: string + readonly projectID: string + readonly workspaceID?: string + readonly directory: string + readonly path?: string + readonly parentID?: string + readonly summary?: { + readonly additions: number + readonly deletions: number + readonly files: number + readonly diffs?: ReadonlyArray<{ + readonly file?: string + readonly patch?: string + readonly additions: number + readonly deletions: number + readonly status?: "added" | "deleted" | "modified" + }> + } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly share?: { readonly url: string } + readonly title: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly version: string + readonly metadata?: { readonly [x: string]: any } + readonly time: { + readonly created: number + readonly updated: number + readonly compacting?: number + readonly archived?: number + } + readonly permission?: ReadonlyArray<{ + readonly permission: string + readonly pattern: string + readonly action: "allow" | "deny" | "ask" + }> + readonly revert?: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.deleted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly info: { + readonly id: string + readonly slug: string + readonly projectID: string + readonly workspaceID?: string + readonly directory: string + readonly path?: string + readonly parentID?: string + readonly summary?: { + readonly additions: number + readonly deletions: number + readonly files: number + readonly diffs?: ReadonlyArray<{ + readonly file?: string + readonly patch?: string + readonly additions: number + readonly deletions: number + readonly status?: "added" | "deleted" | "modified" + }> + } + readonly cost?: number + readonly tokens?: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly share?: { readonly url: string } + readonly title: string + readonly agent?: string + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly version: string + readonly metadata?: { readonly [x: string]: any } + readonly time: { + readonly created: number + readonly updated: number + readonly compacting?: number + readonly archived?: number + } + readonly permission?: ReadonlyArray<{ + readonly permission: string + readonly pattern: string + readonly action: "allow" | "deny" | "ask" + }> + readonly revert?: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "message.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly info: + | { + readonly id: string + readonly sessionID: string + readonly role: "user" + readonly time: { readonly created: number } + readonly format?: + | ( + | { readonly type: "text" } + | { + readonly type: "json_schema" + readonly schema: { readonly [x: string]: any } + readonly retryCount?: number | undefined | undefined + } + ) + | undefined + readonly summary?: + | { + readonly title?: string | undefined + readonly body?: string | undefined + readonly diffs: ReadonlyArray<{ + readonly file?: string + readonly patch?: string + readonly additions: number + readonly deletions: number + readonly status?: "added" | "deleted" | "modified" + }> + } + | undefined + readonly agent: string + readonly model: { + readonly providerID: string + readonly modelID: string + readonly variant?: string | undefined + } + readonly system?: string | undefined + readonly tools?: { readonly [x: string]: boolean } | undefined + } + | { + readonly id: string + readonly sessionID: string + readonly role: "assistant" + readonly time: { readonly created: number; readonly completed?: number | undefined } + readonly error?: + | { + readonly name: "ProviderAuthError" + readonly data: { readonly providerID: string; readonly message: string } + } + | { + readonly name: "UnknownError" + readonly data: { readonly message: string; readonly ref?: string | undefined } + } + | { readonly name: "MessageOutputLengthError"; readonly data: {} } + | { readonly name: "MessageAbortedError"; readonly data: { readonly message: string } } + | { + readonly name: "StructuredOutputError" + readonly data: { readonly message: string; readonly retries: number } + } + | { + readonly name: "ContextOverflowError" + readonly data: { readonly message: string; readonly responseBody?: string | undefined } + } + | { readonly name: "ContentFilterError"; readonly data: { readonly message: string } } + | { + readonly name: "APIError" + readonly data: { + readonly message: string + readonly statusCode?: number | undefined + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } | undefined + readonly responseBody?: string | undefined + readonly metadata?: { readonly [x: string]: string } | undefined + } + } + | undefined + readonly parentID: string + readonly modelID: string + readonly providerID: string + readonly mode: string + readonly agent: string + readonly path: { readonly cwd: string; readonly root: string } + readonly summary?: boolean | undefined + readonly cost: number + readonly tokens: { + readonly total?: number | undefined + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly structured?: any | undefined + readonly variant?: string | undefined + readonly finish?: string | undefined + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "message.removed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly messageID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "message.part.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly part: + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "text" + readonly text: string + readonly synthetic?: boolean | undefined + readonly ignored?: boolean | undefined + readonly time?: { readonly start: number; readonly end?: number | undefined } | undefined + readonly metadata?: { readonly [x: string]: any } | undefined + } + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "subtask" + readonly prompt: string + readonly description: string + readonly agent: string + readonly model?: { readonly providerID: string; readonly modelID: string } | undefined + readonly command?: string | undefined + } + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "reasoning" + readonly text: string + readonly metadata?: { readonly [x: string]: any } | undefined + readonly time: { readonly start: number; readonly end?: number | undefined } + } + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "file" + readonly mime: string + readonly filename?: string | undefined + readonly url: string + readonly source?: + | ( + | { + readonly text: { readonly value: string; readonly start: number; readonly end: number } + readonly type: "file" + readonly path: string + } + | { + readonly text: { readonly value: string; readonly start: number; readonly end: number } + readonly type: "symbol" + readonly path: string + readonly range: { + readonly start: { readonly line: number; readonly character: number } + readonly end: { readonly line: number; readonly character: number } + } + readonly name: string + readonly kind: number + } + | { + readonly text: { readonly value: string; readonly start: number; readonly end: number } + readonly type: "resource" + readonly clientName: string + readonly uri: string + } + ) + | undefined + } + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "tool" + readonly callID: string + readonly tool: string + readonly state: + | { readonly status: "pending"; readonly input: { readonly [x: string]: any }; readonly raw: string } + | { + readonly status: "running" + readonly input: { readonly [x: string]: any } + readonly title?: string | undefined + readonly metadata?: { readonly [x: string]: any } | undefined + readonly time: { readonly start: number } + } + | { + readonly status: "completed" + readonly input: { readonly [x: string]: any } + readonly output: string + readonly title: string + readonly metadata: { readonly [x: string]: any } + readonly time: { + readonly start: number + readonly end: number + readonly compacted?: number | undefined + } + readonly attachments?: + | ReadonlyArray<{ + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "file" + readonly mime: string + readonly filename?: string | undefined + readonly url: string + readonly source?: + | ( + | { + readonly text: { + readonly value: string + readonly start: number + readonly end: number + } + readonly type: "file" + readonly path: string + } + | { + readonly text: { + readonly value: string + readonly start: number + readonly end: number + } + readonly type: "symbol" + readonly path: string + readonly range: { + readonly start: { readonly line: number; readonly character: number } + readonly end: { readonly line: number; readonly character: number } + } + readonly name: string + readonly kind: number + } + | { + readonly text: { + readonly value: string + readonly start: number + readonly end: number + } + readonly type: "resource" + readonly clientName: string + readonly uri: string + } + ) + | undefined + }> + | undefined + } + | { + readonly status: "error" + readonly input: { readonly [x: string]: any } + readonly error: string + readonly metadata?: { readonly [x: string]: any } | undefined + readonly time: { readonly start: number; readonly end: number } + } + readonly metadata?: { readonly [x: string]: any } | undefined + } + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "step-start" + readonly snapshot?: string | undefined + } + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "step-finish" + readonly reason: string + readonly snapshot?: string | undefined + readonly cost: number + readonly tokens: { + readonly total?: number | undefined + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + } + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "snapshot" + readonly snapshot: string + } + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "patch" + readonly hash: string + readonly files: ReadonlyArray + } + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "agent" + readonly name: string + readonly source?: { readonly value: string; readonly start: number; readonly end: number } | undefined + } + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "retry" + readonly attempt: number + readonly error: { + readonly name: "APIError" + readonly data: { + readonly message: string + readonly statusCode?: number | undefined + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } | undefined + readonly responseBody?: string | undefined + readonly metadata?: { readonly [x: string]: string } | undefined + } + } + readonly time: { readonly created: number } + } + | { + readonly id: string + readonly sessionID: string + readonly messageID: string + readonly type: "compaction" + readonly auto: boolean + readonly overflow?: boolean | undefined + readonly tail_start_id?: string | undefined + } + readonly time: number + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "message.part.removed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly messageID: string; readonly partID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.agent.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly agent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.model.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.moved" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subdirectory?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.renamed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.forked" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly parentID: string + readonly messageID?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.prompted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.prompt.admitted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.context.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.synthetic" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.shell.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly callID: string + readonly command: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.shell.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly callID: string + readonly output: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly snapshot?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly finish: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly snapshot?: string + readonly files?: ReadonlyArray + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.step.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly error: { readonly type: "unknown"; readonly message: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.text.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.text.delta" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + readonly delta: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.text.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.reasoning.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.reasoning.delta" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly delta: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.reasoning.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.input.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly name: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.input.delta" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly delta: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.input.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.called" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly tool: string + readonly input: { readonly [x: string]: unknown } + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.progress" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: unknown } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.success" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: unknown } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + readonly outputPaths?: ReadonlyArray + readonly result?: unknown + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.tool.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: unknown + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.retried" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly attempt: number + readonly error: { + readonly message: string + readonly statusCode?: number + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } + readonly responseBody?: string + readonly metadata?: { readonly [x: string]: string } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.compaction.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.compaction.delta" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.compaction.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + readonly text: string + readonly recent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.staged" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly revert: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.cleared" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.revert.committed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "file.edited" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly file: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "reference.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: {} + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "permission.v2.asked" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly id: string + readonly sessionID: string + readonly action: string + readonly resources: ReadonlyArray + readonly save?: ReadonlyArray + readonly metadata?: { readonly [x: string]: unknown } + readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "permission.v2.replied" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly requestID: string + readonly reply: "once" | "always" | "reject" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "plugin.added" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly id: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "project.directories.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly projectID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "file.watcher.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "pty.created" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly info: { + readonly id: string + readonly title: string + readonly command: string + readonly args: ReadonlyArray + readonly cwd: string + readonly status: "running" | "exited" + readonly pid: number + readonly exitCode?: number + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "pty.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly info: { + readonly id: string + readonly title: string + readonly command: string + readonly args: ReadonlyArray + readonly cwd: string + readonly status: "running" | "exited" + readonly pid: number + readonly exitCode?: number + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "pty.exited" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly id: string; readonly exitCode: number } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "pty.deleted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly id: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "shell.created" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly info: { + readonly id: string + readonly status: "running" | "exited" | "timeout" | "killed" + readonly command: string + readonly cwd: string + readonly shell: string + readonly file: string + readonly pid?: number + readonly exit?: number + readonly metadata: { readonly [x: string]: unknown } + readonly time: { readonly started: number; readonly completed?: number } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "shell.exited" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly id: string + readonly exit?: number + readonly status: "running" | "exited" | "timeout" | "killed" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "shell.deleted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly id: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "question.v2.asked" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly id: string + readonly sessionID: string + readonly questions: ReadonlyArray<{ + readonly question: string + readonly header: string + readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }> + readonly multiple?: boolean + readonly custom?: boolean + }> + readonly tool?: { readonly messageID: string; readonly callID: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "question.v2.replied" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly requestID: string + readonly answers: ReadonlyArray> + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "question.v2.rejected" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly sessionID: string; readonly requestID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "todo.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly sessionID: string + readonly todos: ReadonlyArray<{ readonly content: string; readonly status: string; readonly priority: string }> + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } | undefined + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined + readonly location?: { readonly directory: string; readonly workspaceID?: string } | undefined + readonly type: "server.connected" + readonly data: {} + } -export type PtysListInput = { +export type PtyListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type PtysListOutput = { +export type PtyListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2706,7 +4022,7 @@ export type PtysListOutput = { }> } -export type PtysCreateInput = { +export type PtyCreateInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] @@ -2747,7 +4063,7 @@ export type PtysCreateInput = { }["env"] } -export type PtysCreateOutput = { +export type PtyCreateOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2765,14 +4081,14 @@ export type PtysCreateOutput = { } } -export type PtysGetInput = { +export type PtyGetInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type PtysGetOutput = { +export type PtyGetOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2790,7 +4106,7 @@ export type PtysGetOutput = { } } -export type PtysUpdateInput = { +export type PtyUpdateInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -2802,7 +4118,7 @@ export type PtysUpdateInput = { readonly size?: { readonly title?: string; readonly size?: { readonly rows: number; readonly cols: number } }["size"] } -export type PtysUpdateOutput = { +export type PtyUpdateOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2820,22 +4136,22 @@ export type PtysUpdateOutput = { } } -export type PtysRemoveInput = { +export type PtyRemoveInput = { readonly ptyID: { readonly ptyID: string }["ptyID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type PtysRemoveOutput = void +export type PtyRemoveOutput = void -export type ServerShellListInput = { +export type ShellListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type ServerShellListOutput = { +export type ShellListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2858,7 +4174,7 @@ export type ServerShellListOutput = { }> } -export type ServerShellCreateInput = { +export type ShellCreateInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] @@ -2888,7 +4204,7 @@ export type ServerShellCreateInput = { }["metadata"] } -export type ServerShellCreateOutput = { +export type ShellCreateOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2911,14 +4227,14 @@ export type ServerShellCreateOutput = { } } -export type ServerShellGetInput = { +export type ShellGetInput = { readonly id: { readonly id: string }["id"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type ServerShellGetOutput = { +export type ShellGetOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2941,7 +4257,7 @@ export type ServerShellGetOutput = { } } -export type ServerShellOutputInput = { +export type ShellOutputInput = { readonly id: { readonly id: string }["id"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -2960,7 +4276,7 @@ export type ServerShellOutputInput = { }["limit"] } -export type ServerShellOutputOutput = { +export type ShellOutputOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -2974,22 +4290,22 @@ export type ServerShellOutputOutput = { } } -export type ServerShellRemoveInput = { +export type ShellRemoveInput = { readonly id: { readonly id: string }["id"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type ServerShellRemoveOutput = void +export type ShellRemoveOutput = void -export type QuestionsListRequestsInput = { +export type QuestionListRequestsInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type QuestionsListRequestsOutput = { +export type QuestionListRequestsOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -3009,9 +4325,9 @@ export type QuestionsListRequestsOutput = { }> } -export type QuestionsListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } +export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } -export type QuestionsListOutput = { +export type QuestionListOutput = { readonly data: ReadonlyArray<{ readonly id: string readonly sessionID: string @@ -3026,28 +4342,28 @@ export type QuestionsListOutput = { }> }["data"] -export type QuestionsReplyInput = { +export type QuestionReplyInput = { readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] readonly answers: { readonly answers: ReadonlyArray> }["answers"] } -export type QuestionsReplyOutput = void +export type QuestionReplyOutput = void -export type QuestionsRejectInput = { +export type QuestionRejectInput = { readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"] readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"] } -export type QuestionsRejectOutput = void +export type QuestionRejectOutput = void -export type ReferencesListInput = { +export type ReferenceListInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type ReferencesListOutput = { +export type ReferenceListOutput = { readonly location: { readonly directory: string readonly workspaceID?: string @@ -3070,7 +4386,7 @@ export type ReferencesListOutput = { }> } -export type ProjectCopiesCreateInput = { +export type ProjectCopyCreateInput = { readonly projectID: { readonly projectID: string }["projectID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -3080,9 +4396,9 @@ export type ProjectCopiesCreateInput = { readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"] } -export type ProjectCopiesCreateOutput = { readonly directory: string } +export type ProjectCopyCreateOutput = { readonly directory: string } -export type ProjectCopiesRemoveInput = { +export type ProjectCopyRemoveInput = { readonly projectID: { readonly projectID: string }["projectID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -3091,13 +4407,13 @@ export type ProjectCopiesRemoveInput = { readonly force: { readonly directory: string; readonly force: boolean }["force"] } -export type ProjectCopiesRemoveOutput = void +export type ProjectCopyRemoveOutput = void -export type ProjectCopiesRefreshInput = { +export type ProjectCopyRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined }["location"] } -export type ProjectCopiesRefreshOutput = void +export type ProjectCopyRefreshOutput = void diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 82b671ea39..e9e848b160 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1,3 +1,3 @@ export * from "./generated/index" -export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types" +export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types" export type OpenCodeClient = ReturnType diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 7bf4d26f8f..37105dfa23 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -3,19 +3,19 @@ import { DateTime, Effect, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect" -test("sessions.get returns the decoded Effect projection", async () => { +test("session.get returns the decoded Effect projection", async () => { const httpClient = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))), ) const result = await Effect.gen(function* () { const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) - return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") }) + return yield* client.session.get({ sessionID: Session.ID.make("ses_test") }) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000) }) -test("events.subscribe exposes and decodes the native Effect event stream", async () => { +test("event.subscribe exposes and decodes the native Effect event stream", async () => { const httpClient = HttpClient.make((request) => Effect.succeed( HttpClientResponse.fromWeb( @@ -30,7 +30,7 @@ test("events.subscribe exposes and decodes the native Effect event stream", asyn ) const events = await Effect.gen(function* () { const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) - return yield* client.events.subscribe().pipe(Stream.runCollect) + return yield* client.event.subscribe().pipe(Stream.runCollect) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"]) @@ -40,7 +40,7 @@ test("events.subscribe exposes and decodes the native Effect event stream", asyn expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 }) }) -test("events.subscribe terminates on Effect protocol decode failures", async () => { +test("event.subscribe terminates on Effect protocol decode failures", async () => { const httpClient = HttpClient.make((request) => Effect.succeed( HttpClientResponse.fromWeb( @@ -53,7 +53,7 @@ test("events.subscribe terminates on Effect protocol decode failures", async () ) const error = await Effect.gen(function* () { const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) - return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip) + return yield* client.event.subscribe().pipe(Stream.runCollect, Effect.flip) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) expect(error._tag).toBe("ClientError") @@ -112,41 +112,41 @@ test("session methods retain decoded Effect inputs and outputs", async () => { }) const result = await Effect.gen(function* () { const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) - const page = yield* client.sessions.list({ limit: 10 }) - const active = yield* client.sessions.active() - const created = yield* client.sessions.create({ + const page = yield* client.session.list({ limit: 10 }) + const active = yield* client.session.active() + const created = yield* client.session.create({ location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), }) - yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") }) - yield* client.sessions.switchModel({ + yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") }) + yield* client.session.switchModel({ sessionID: Session.ID.make("ses_test"), model: Model.Ref.make({ id: "claude", providerID: "anthropic" }), }) - const admitted = yield* client.sessions.prompt({ + const admitted = yield* client.session.prompt({ sessionID: Session.ID.make("ses_test"), prompt: Prompt.make({ text: "Hello" }), resume: false, }) - yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) - yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") }) - const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) - const history = yield* client.sessions.history({ + yield* client.session.compact({ sessionID: Session.ID.make("ses_test") }) + yield* client.session.wait({ sessionID: Session.ID.make("ses_test") }) + const context = yield* client.session.context({ sessionID: Session.ID.make("ses_test") }) + const history = yield* client.session.history({ sessionID: Session.ID.make("ses_test"), after: 0, limit: 1, }) const historyNext = history.hasMore - ? yield* client.sessions.history({ + ? yield* client.session.history({ sessionID: Session.ID.make("ses_test"), after: history.data.at(-1)?.durable?.seq, limit: 2, }) : undefined - const events = yield* client.sessions + const events = yield* client.session .events({ sessionID: Session.ID.make("ses_test"), after: 0 }) .pipe(Stream.runCollect) - yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") }) - const message = yield* client.sessions.message({ + yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") }) + const message = yield* client.session.message({ sessionID: Session.ID.make("ses_test"), messageID: SessionMessage.ID.make("msg_model"), }) @@ -171,7 +171,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) }) -test("sessions.history retains the typed SessionNotFoundError", async () => { +test("session.history retains the typed SessionNotFoundError", async () => { const httpClient = HttpClient.make((request) => Effect.succeed( HttpClientResponse.fromWeb( @@ -185,7 +185,7 @@ test("sessions.history retains the typed SessionNotFoundError", async () => { ) const error = await Effect.gen(function* () { const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) - return yield* client.sessions + return yield* client.session .history({ sessionID: Session.ID.make("ses_missing"), }) diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index d00db17d85..bf7e441c09 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -7,28 +7,28 @@ test("exposes every standard HTTP API group", () => { expect(Object.keys(client)).toEqual([ "health", "location", - "agents", - "sessions", - "messages", - "models", + "agent", + "session", + "message", + "model", "generate", - "providers", - "integrations", - "credentials", + "provider", + "integration", + "credential", "project", - "permissions", - "files", - "commands", - "skills", - "events", - "ptys", - "server.shell", - "questions", - "references", - "projectCopies", + "permission", + "file", + "command", + "skill", + "event", + "pty", + "shell", + "question", + "reference", + "projectCopy", ]) - expect(Object.keys(client.messages)).toEqual(["list"]) - expect(Object.keys(client.integrations)).toEqual([ + expect(Object.keys(client.message)).toEqual(["list"]) + expect(Object.keys(client.integration)).toEqual([ "list", "get", "connectKey", @@ -37,12 +37,13 @@ test("exposes every standard HTTP API group", () => { "attemptComplete", "attemptCancel", ]) - expect(Object.keys(client.files)).toEqual(["read", "list", "find"]) - expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"]) + expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) + expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) + expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"]) expect(Object.keys(client.project)).toEqual(["current", "directories"]) }) -test("files.read returns binary content from the public HTTP contract", async () => { +test("file.read returns binary content from the public HTTP contract", async () => { let request: Request | undefined const client = OpenCode.make({ baseUrl: "http://localhost:3000", @@ -52,7 +53,7 @@ test("files.read returns binary content from the public HTTP contract", async () }, }) - const content = await client.files.read({ + const content = await client.file.read({ path: "src/a b#c.ts", location: { directory: "/tmp/project" }, }) @@ -89,7 +90,42 @@ test("project methods use the public HTTP contract", async () => { ]) }) -test("sessions.get returns the wire projection", async () => { +test("shell list and remove use the public HTTP contract", async () => { + const requests: Array<{ method: string; url: string }> = [] + const shell = { + id: "sh_test", + status: "running", + command: "pwd", + cwd: "/tmp/project", + shell: "/bin/zsh", + file: "/tmp/opencode-shell", + metadata: { sessionID: "ses_test" }, + time: { started: 1_717_171_717_000 }, + } + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + requests.push({ method: request.method, url: request.url }) + if (request.method === "DELETE") return new Response(null, { status: 204 }) + return Response.json({ + location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } }, + data: [shell], + }) + }, + }) + + const result = await client.shell.list({ location: { directory: "/tmp/project" } }) + await client.shell.remove({ id: shell.id }) + + expect(result.data).toEqual([shell]) + expect(requests).toEqual([ + { method: "GET", url: "http://localhost:3000/api/shell?location%5Bdirectory%5D=%2Ftmp%2Fproject" }, + { method: "DELETE", url: "http://localhost:3000/api/shell/sh_test" }, + ]) +}) + +test("session.get returns the wire projection", async () => { const client = OpenCode.make({ baseUrl: "http://localhost:3000", fetch: async (input) => { @@ -100,12 +136,12 @@ test("sessions.get returns the wire projection", async () => { }, }) - const result = await client.sessions.get({ sessionID: "ses_test" }) + const result = await client.session.get({ sessionID: "ses_test" }) expect(result.time.created).toBe(1_717_171_717_000) }) -test("events.subscribe exposes the Promise event stream wire projection", async () => { +test("event.subscribe exposes the Promise event stream wire projection", async () => { const client = OpenCode.make({ baseUrl: "http://localhost:3000", fetch: async () => @@ -116,19 +152,19 @@ test("events.subscribe exposes the Promise event stream wire projection", async ), }) const events = [] - for await (const event of client.events.subscribe()) events.push(event) + for await (const event of client.event.subscribe()) events.push(event) expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent]) expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000) }) -test("events.subscribe terminates on malformed Promise SSE data", async () => { +test("event.subscribe terminates on malformed Promise SSE data", async () => { const client = OpenCode.make({ baseUrl: "http://localhost:3000", fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }), }) - await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({ name: "ClientError", reason: "MalformedResponse", }) @@ -163,31 +199,31 @@ test("session methods use the public HTTP contract", async () => { }, }) - const page = await client.sessions.list({ limit: 10, order: "desc" }) - const active = await client.sessions.active() - const created = await client.sessions.create({ location: { directory: "/tmp/project" } }) - await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" }) - await client.sessions.switchModel({ + const page = await client.session.list({ limit: 10, order: "desc" }) + const active = await client.session.active() + const created = await client.session.create({ location: { directory: "/tmp/project" } }) + await client.session.switchAgent({ sessionID: "ses_test", agent: "build" }) + await client.session.switchModel({ sessionID: "ses_test", model: { id: "claude", providerID: "anthropic" }, }) - const admitted = await client.sessions.prompt({ + const admitted = await client.session.prompt({ sessionID: "ses_test", prompt: { text: "Hello" }, resume: false, }) - await client.sessions.compact({ sessionID: "ses_test" }) - await client.sessions.wait({ sessionID: "ses_test" }) - const context = await client.sessions.context({ sessionID: "ses_test" }) - const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 }) + await client.session.compact({ sessionID: "ses_test" }) + await client.session.wait({ sessionID: "ses_test" }) + const context = await client.session.context({ sessionID: "ses_test" }) + const history = await client.session.history({ sessionID: "ses_test", after: 0, limit: 1 }) const historyAfter = history.data.at(-1)?.durable?.seq const historyNext = history.hasMore - ? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 }) + ? await client.session.history({ sessionID: "ses_test", after: historyAfter, limit: 2 }) : undefined const events = [] - for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event) - await client.sessions.interrupt({ sessionID: "ses_test" }) - const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" }) + for await (const event of client.session.events({ sessionID: "ses_test", after: 0 })) events.push(event) + await client.session.interrupt({ sessionID: "ses_test" }) + const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" }) expect(page.cursor.next).toBe("next") expect(active).toEqual({ ses_test: { type: "running" } }) @@ -230,14 +266,14 @@ test("middleware errors remain declared client errors", async () => { }) try { - await client.sessions.create({}) + await client.session.create({}) throw new Error("Expected request to fail") } catch (error) { expect(isUnauthorizedError(error)).toBe(true) } }) -test("sessions.history decodes SessionNotFoundError", async () => { +test("session.history decodes SessionNotFoundError", async () => { const client = OpenCode.make({ baseUrl: "http://localhost:3000", fetch: async () => @@ -248,7 +284,7 @@ test("sessions.history decodes SessionNotFoundError", async () => { }) try { - await client.sessions.history({ sessionID: "ses_missing" }) + await client.session.history({ sessionID: "ses_missing" }) throw new Error("Expected request to fail") } catch (error) { expect(isSessionNotFoundError(error)).toBe(true) diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index 20beb09ded..a1ef0b05e7 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -1,5 +1,5 @@ import { TextAttributes } from "@opentui/core" -import type { IntegrationsConnectOauthOutput } from "@opencode-ai/client" +import type { IntegrationConnectOauthOutput } from "@opencode-ai/client" import type { ConnectionInfo, IntegrationInfo, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2" import { createMemo, createSignal, onCleanup, onMount, Show } from "solid-js" import { useClipboard } from "../context/clipboard" @@ -23,7 +23,7 @@ const INTEGRATION_PRIORITY: Record = { } type ConnectMethod = Exclude -type IntegrationAttempt = IntegrationsConnectOauthOutput["data"] +type IntegrationAttempt = IntegrationConnectOauthOutput["data"] export function integrationOptions(list: IntegrationInfo[]) { return list.toSorted( @@ -111,7 +111,7 @@ function manageConnections( title: `Disconnect ${connection.label}`, value: connection.id, onSelect: () => { - void sdk.api.credentials + void sdk.api.credential .remove({ credentialID: connection.id, location: location(data) }) .then(() => disconnected(integration.name, data, dialog, toast)) .catch(toast.error) @@ -159,7 +159,7 @@ function KeyMethod(props: { integration: IntegrationInfo; method: Extract { if (!key) return - void sdk.api.integrations + void sdk.api.integration .connectKey({ integrationID: props.integration.id, location: location(data), @@ -194,7 +194,7 @@ function OAuthStarting(props: { const toast = useToast() onMount(() => { - void sdk.api.integrations + void sdk.api.integration .connectOauth({ integrationID: props.integration.id, location: location(data), @@ -248,7 +248,7 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt })) const poll = () => { - void sdk.api.integrations + void sdk.api.integration .attemptStatus({ attemptID: props.attempt.attemptID, location: location(data) }) .then((result) => { const status = result.data @@ -275,7 +275,7 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt onCleanup(() => { if (timer) clearTimeout(timer) if (settled) return - void sdk.api.integrations.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) }) return ( @@ -300,7 +300,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt onCleanup(() => { if (settled) return - void sdk.api.integrations.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) + void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) }) }) return ( @@ -309,7 +309,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt placeholder="Authorization code" onConfirm={(code) => { if (!code) return - void sdk.api.integrations + void sdk.api.integration .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index e4f29686d5..7cf6bc1ad7 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -77,7 +77,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { async (projectID, info): Promise | undefined> => { try { const location = { directory: projectContext.instance.directory() || paths.cwd } - await sdk.api.projectCopies.refresh({ + await sdk.api.projectCopy.refresh({ projectID, location, }) @@ -227,7 +227,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { setToDelete(undefined) setRemoving(selected.directory) setWorking(true) - const error = await sdk.api.projectCopies + const error = await sdk.api.projectCopy .remove({ projectID: props.projectID, location: { directory: projectContext.instance.directory() || paths.cwd }, @@ -252,7 +252,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { return } reopen(selected.directory) - const forcedError = await sdk.api.projectCopies + const forcedError = await sdk.api.projectCopy .remove({ projectID: props.projectID, location: { directory: projectContext.instance.directory() || paths.cwd }, diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index fc1915e398..3713e9fc47 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -32,7 +32,7 @@ export function DialogSessionList() { const [searchResults] = createResource(search, async (query) => { if (!query) return const location = data.location.default() - const response = await sdk.api.sessions.list({ + const response = await sdk.api.session.list({ search: query, limit: 50, order: "desc", diff --git a/packages/tui/src/component/dialog-session-rename.tsx b/packages/tui/src/component/dialog-session-rename.tsx index 1b9e3372cd..f2b1d07832 100644 --- a/packages/tui/src/component/dialog-session-rename.tsx +++ b/packages/tui/src/component/dialog-session-rename.tsx @@ -17,7 +17,7 @@ export function DialogSessionRename(props: { sessionID: string; currentTitle?: s onConfirm={(value) => { const title = value.trim() if (!title) return - void sdk.api.sessions + void sdk.api.session .rename({ sessionID: props.sessionID, title }) .then(() => dialog.clear()) .catch((error) => diff --git a/packages/tui/src/component/dialog-tag.tsx b/packages/tui/src/component/dialog-tag.tsx index 2b3dc83524..aa32c98ab7 100644 --- a/packages/tui/src/component/dialog-tag.tsx +++ b/packages/tui/src/component/dialog-tag.tsx @@ -17,7 +17,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) { const [files] = createResource( () => [store.filter], async () => { - const result = await sdk.api.files + const result = await sdk.api.file .find({ query: store.filter, type: "file", diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 38e387e499..6d3e341250 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -320,7 +320,7 @@ export function Autocomplete(props: { if (referenceMatch()) return [] const { lineRange, baseQuery } = extractLineRange(input.query ?? "") - const result = await sdk.api.files + const result = await sdk.api.file .find({ query: baseQuery, limit: 20, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index f868f33679..288fdaf710 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -425,7 +425,7 @@ export function Prompt(props: PromptProps) { }, 5000) if (store.interrupt >= 2) { - void sdk.api.sessions.interrupt({ + void sdk.api.session.interrupt({ sessionID: props.sessionID, }) setStore("interrupt", 0) @@ -1012,7 +1012,7 @@ export function Prompt(props: PromptProps) { finishMoveProgress = Boolean(move.progress()) const location = data.location.default() - const created = await sdk.api.sessions + const created = await sdk.api.session .create({ location: directory ? { directory, workspaceID } @@ -1114,20 +1114,20 @@ export function Prompt(props: PromptProps) { session = data.session.get(sessionID) } if (session?.agent !== agent.id) { - await sdk.api.sessions.switchAgent({ sessionID, agent: agent.id }) + await sdk.api.session.switchAgent({ sessionID, agent: agent.id }) } if ( session?.model?.providerID !== selectedModel.providerID || session.model.id !== selectedModel.modelID || session.model.variant !== variant ) { - await sdk.api.sessions.switchModel({ + await sdk.api.session.switchModel({ sessionID, model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, }) } if (session?.revert) { - const error = await sdk.api.sessions.commit({ sessionID }).then( + const error = await sdk.api.session.commit({ sessionID }).then( () => undefined, (error) => error, ) @@ -1136,7 +1136,7 @@ export function Prompt(props: PromptProps) { return false } } - const error = await sdk.api.sessions + const error = await sdk.api.session .prompt({ sessionID, prompt: { diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index b567fd97f4..9fdd5c9d6a 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -37,7 +37,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess { projectID, context }, { throwOnError: true }, ) - const result = await sdk.api.projectCopies.create({ + const result = await sdk.api.projectCopy.create({ projectID, location: { directory: project.instance.directory() || paths.cwd }, strategy: "git_worktree", diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index c14516b0af..ae56634a22 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -554,7 +554,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.status[sessionID] ?? "idle" }, async refresh(sessionID: string) { - setStore("session", "info", sessionID, mutable(await sdk.api.sessions.get({ sessionID }))) + setStore("session", "info", sessionID, mutable(await sdk.api.session.get({ sessionID }))) }, message: { ids(sessionID: string) { @@ -572,7 +572,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("session", "message", sessionID, []) messageIndex.set(sessionID, new Map()) const loaded = mutable( - (await sdk.api.messages.list({ sessionID, limit: 200, order: "desc" })).data, + (await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data, ).toReversed() const live = store.session.message[sessionID] ?? [] const liveByID = new Map(live.map((message) => [message.id, message])) @@ -588,7 +588,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.permission[sessionID] }, async refresh(sessionID: string) { - setStore("session", "permission", sessionID, mutable(await sdk.api.permissions.list({ sessionID }))) + setStore("session", "permission", sessionID, mutable(await sdk.api.permission.list({ sessionID }))) }, }, question: { @@ -596,7 +596,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.session.question[sessionID] }, async refresh(sessionID: string) { - setStore("session", "question", sessionID, mutable(await sdk.api.questions.list({ sessionID }))) + setStore("session", "question", sessionID, mutable(await sdk.api.question.list({ sessionID }))) }, }, }, @@ -606,7 +606,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.project.permission[projectID] }, async refresh(projectID: string) { - setStore("project", "permission", projectID, mutable(await sdk.api.permissions.listSaved({ projectID }))) + setStore("project", "permission", projectID, mutable(await sdk.api.permission.listSaved({ projectID }))) }, }, }, @@ -618,16 +618,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.shell[id] }, async refresh(ref?: LocationRef) { - const result = await sdk.client.v2.shell.list({ location: locationQuery(ref) }, { throwOnError: true }) + const result = await sdk.api.shell.list({ location: locationQuery(ref) }) setStore( "shell", produce((draft) => { - for (const info of result.data.data) draft[info.id] = info + for (const info of mutable(result.data)) draft[info.id] = info }), ) }, async remove(id: string) { - await sdk.client.v2.shell.remove({ id }, { throwOnError: true }) + await sdk.api.shell.remove({ id }) setStore("shell", id, undefined!) }, }, @@ -646,7 +646,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.agent }, async refresh(ref?: LocationRef) { - const result = await sdk.api.agents.list({ location: locationQuery(ref ?? defaultLocation()) }) + const result = await sdk.api.agent.list({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(result.location) setStore("location", key, { ...store.location[key], agent: mutable(result.data) }) }, @@ -656,7 +656,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.command }, async refresh(ref?: LocationRef) { - const result = await sdk.api.commands.list({ location: locationQuery(ref ?? defaultLocation()) }) + const result = await sdk.api.command.list({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(result.location) setStore("location", key, { ...store.location[key], command: mutable(result.data) }) }, @@ -666,7 +666,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.integration }, async refresh(ref?: LocationRef) { - const result = await sdk.api.integrations.list({ location: locationQuery(ref ?? defaultLocation()) }) + const result = await sdk.api.integration.list({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(result.location) setStore("location", key, { ...store.location[key], integration: mutable(result.data) }) }, @@ -676,7 +676,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.model }, async refresh(ref?: LocationRef) { - const result = await sdk.api.models.list({ location: locationQuery(ref ?? defaultLocation()) }) + const result = await sdk.api.model.list({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(result.location) setStore("location", key, { ...store.location[key], model: mutable(result.data) }) }, @@ -686,7 +686,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.provider }, async refresh(ref?: LocationRef) { - const result = await sdk.api.providers.list({ location: locationQuery(ref ?? defaultLocation()) }) + const result = await sdk.api.provider.list({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(result.location) setStore("location", key, { ...store.location[key], provider: mutable(result.data) }) }, @@ -696,7 +696,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.reference }, async refresh(ref?: LocationRef) { - const result = await sdk.api.references.list({ location: locationQuery(ref ?? defaultLocation()) }) + const result = await sdk.api.reference.list({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(result.location) setStore("location", key, { ...store.location[key], reference: mutable(result.data) }) }, @@ -706,7 +706,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return store.location[locationKey(location ?? defaultLocation())]?.skill }, async refresh(ref?: LocationRef) { - const result = await sdk.api.skills.list({ location: locationQuery(ref ?? defaultLocation()) }) + const result = await sdk.api.skill.list({ location: locationQuery(ref ?? defaultLocation()) }) const key = locationKey(result.location) setStore("location", key, { ...store.location[key], skill: mutable(result.data) }) }, @@ -716,7 +716,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ async function bootstrap() { const settled = await Promise.allSettled([ - sdk.api.sessions + sdk.api.session .list({ limit: 50, order: "desc", @@ -732,7 +732,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }), ), ), - sdk.api.sessions + sdk.api.session .active() .then((active) => setStore( diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index 2fa53ef377..b1f376c740 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -22,7 +22,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string; set value: "session.revert", description: "undo messages and file changes", onSelect: async (dialog) => { - await sdk.api.sessions + await sdk.api.session .stage({ sessionID: props.sessionID, messageID: props.messageID }) .catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })) dialog.clear() diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6109074df0..95ec719d59 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -369,7 +369,7 @@ export function Session() { aliases: ["summarize"], }, run: () => { - void sdk.api.sessions.compact({ sessionID: route.sessionID }) + void sdk.api.session.compact({ sessionID: route.sessionID }) dialog.clear() }, }, @@ -403,7 +403,7 @@ export function Session() { dialog.clear() return } - const error = await sdk.api.sessions.stage({ sessionID: route.sessionID, messageID: target }).then( + const error = await sdk.api.session.stage({ sessionID: route.sessionID, messageID: target }).then( () => undefined, (error) => error, ) @@ -420,7 +420,7 @@ export function Session() { slash: { name: "redo" }, run: () => { void (async () => { - const error = await sdk.api.sessions.clear({ sessionID: route.sessionID }).then( + const error = await sdk.api.session.clear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) @@ -843,7 +843,12 @@ export function Session() { // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) - createEffect(on(() => route.sessionID, () => setComposer("open", false))) + createEffect( + on( + () => route.sessionID, + () => setComposer("open", false), + ), + ) return ( @@ -913,9 +918,7 @@ export function Session() { onClose={() => setComposer("open", false)} /> - - {null} - + {null} 0}> @@ -1230,7 +1233,7 @@ function RevertMessage(props: { onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return void (async () => { - const error = await sdk.api.sessions.clear({ sessionID: route.sessionID }).then( + const error = await sdk.api.session.clear({ sessionID: route.sessionID }).then( () => undefined, (error) => error, ) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 35230f1160..a137d6272a 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -186,7 +186,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director onSelect={(option) => { setStore("stage", "permission") if (option === "cancel") return - void sdk.api.permissions.reply({ + void sdk.api.permission.reply({ sessionID: props.request.sessionID, reply: "always", requestID: props.request.id, @@ -197,7 +197,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director { - void sdk.api.permissions.reply({ + void sdk.api.permission.reply({ sessionID: props.request.sessionID, reply: "reject", requestID: props.request.id, @@ -443,14 +443,14 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director setStore("stage", "reject") return } - void sdk.api.permissions.reply({ + void sdk.api.permission.reply({ sessionID: props.request.sessionID, reply: "reject", requestID: props.request.id, }) return } - void sdk.api.permissions.reply({ + void sdk.api.permission.reply({ sessionID: props.request.sessionID, reply: "once", requestID: props.request.id, diff --git a/packages/tui/src/routes/session/question.tsx b/packages/tui/src/routes/session/question.tsx index 9bbabaaaee..8659884a45 100644 --- a/packages/tui/src/routes/session/question.tsx +++ b/packages/tui/src/routes/session/question.tsx @@ -47,7 +47,7 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: function submit() { const answers = questions().map((_, i) => store.answers[i] ?? []) - void sdk.api.questions.reply({ + void sdk.api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers, @@ -55,7 +55,7 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: } function reject() { - void sdk.api.questions.reject({ + void sdk.api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id, }) @@ -71,7 +71,7 @@ export function QuestionPrompt(props: { request: QuestionV2Request; directory?: setStore("custom", inputs) } if (single()) { - void sdk.api.questions.reply({ + void sdk.api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers: [[answer]], From 461a1c3ab428345008cfe9f11e23c6e4b9d1878b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 29 Jun 2026 23:53:35 -0400 Subject: [PATCH 16/27] refactor(core): replace background job service (#34559) --- .../core/src/{background-job.ts => job.ts} | 275 +++++++++--------- packages/core/src/tool/shell.ts | 44 +-- packages/core/src/tool/subagent.ts | 42 +-- packages/core/test/background-job.test.ts | 103 ------- packages/core/test/job.test.ts | 164 +++++++++++ packages/core/test/tool-shell.test.ts | 24 +- packages/core/test/tool-subagent.test.ts | 8 +- packages/opencode/src/effect/app-runtime.ts | 4 +- packages/opencode/src/{background => }/job.ts | 24 +- .../instance/httpapi/handlers/experimental.ts | 14 +- .../server/routes/instance/httpapi/server.ts | 4 +- packages/opencode/src/session/run-state.ts | 28 +- packages/opencode/src/session/session.ts | 23 +- packages/opencode/src/tool/registry.ts | 6 +- packages/opencode/src/tool/task.ts | 53 ++-- packages/opencode/test/AGENTS.md | 2 +- packages/opencode/test/background/job.test.ts | 243 ---------------- packages/opencode/test/job.test.ts | 131 +++++++++ .../opencode/test/server/session-list.test.ts | 4 +- packages/opencode/test/session/prompt.test.ts | 4 +- .../opencode/test/session/session.test.ts | 4 +- packages/opencode/test/tool/task.test.ts | 58 ++-- specs/v2/schema-changelog.md | 2 +- specs/v2/todo.md | 2 +- 24 files changed, 593 insertions(+), 673 deletions(-) rename packages/core/src/{background-job.ts => job.ts} (53%) delete mode 100644 packages/core/test/background-job.test.ts create mode 100644 packages/core/test/job.test.ts rename packages/opencode/src/{background => }/job.ts (55%) delete mode 100644 packages/opencode/test/background/job.test.ts create mode 100644 packages/opencode/test/job.test.ts diff --git a/packages/core/src/background-job.ts b/packages/core/src/job.ts similarity index 53% rename from packages/core/src/background-job.ts rename to packages/core/src/job.ts index 4287830bf1..1662ea2b8b 100644 --- a/packages/core/src/background-job.ts +++ b/packages/core/src/job.ts @@ -1,8 +1,9 @@ -export * as BackgroundJob from "./background-job" +export * as Job from "./job" import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect" -import { Identifier } from "./id/id" import { makeGlobalNode } from "./effect/app-node" +import { Identifier } from "./id/id" +import { SessionSchema } from "./session/schema" export type Status = "running" | "completed" | "error" | "cancelled" @@ -21,14 +22,11 @@ export type Info = { type Active = { info: Info done: Deferred.Deferred + backgrounded: Deferred.Deferred scope: Scope.Closeable token: object - pending: number - next: number - output?: { sequence: number; text: string } - tail: Deferred.Deferred - promoted: Deferred.Deferred - onPromote?: Effect.Effect + blockingSessions: Map + isBackgrounded: boolean } type State = { @@ -42,36 +40,29 @@ type FinishResult = { scope?: Scope.Closeable } -type PromoteResult = { +type BackgroundResult = { info?: Info - promoted?: Deferred.Deferred - onPromote?: Effect.Effect + backgrounded?: Deferred.Deferred } type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object } -type ExtendResult = - | { extended: false } - | { - extended: true - previous: Deferred.Deferred - scope: Scope.Closeable - tail: Deferred.Deferred - token: object - sequence: number - } +type BlockWait = { + done: Deferred.Deferred + backgrounded: Deferred.Deferred +} + +type BlockStart = + | { type: "missing" } + | { type: "finished"; info: Info } + | { type: "backgrounded"; info: Info } + | { type: "wait"; wait: BlockWait } export type StartInput = { id?: string type: string title?: string metadata?: Record - onPromote?: Effect.Effect - run: Effect.Effect -} - -export type ExtendInput = { - id: string run: Effect.Effect } @@ -85,18 +76,30 @@ export type WaitResult = { timedOut: boolean } +export type BlockInput = { + id: string + sessionID: SessionSchema.ID +} + +export type BlockResult = { type: "finished"; info: Info } | { type: "backgrounded"; info: Info } + +export type BackgroundAllInput = { + sessionID: SessionSchema.ID + type?: string +} + export interface Interface { readonly list: () => Effect.Effect readonly get: (id: string) => Effect.Effect readonly start: (input: StartInput) => Effect.Effect - readonly extend: (input: ExtendInput) => Effect.Effect readonly wait: (input: WaitInput) => Effect.Effect - readonly waitForPromotion: (id: string) => Effect.Effect - readonly promote: (id: string) => Effect.Effect + readonly block: (input: BlockInput) => Effect.Effect + readonly background: (id: string) => Effect.Effect + readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect readonly cancel: (id: string) => Effect.Effect } -export class Service extends Context.Service()("@opencode/BackgroundJob") {} +export class Service extends Context.Service()("@opencode/Job") {} function snapshot(job: Active): Info { return { @@ -110,6 +113,19 @@ function errorText(error: unknown) { return String(error) } +function incrementSession(input: Map, sessionID: SessionSchema.ID) { + return new Map(input).set(sessionID, (input.get(sessionID) ?? 0) + 1) +} + +function decrementSession(input: Map, sessionID: SessionSchema.ID) { + const count = input.get(sessionID) + if (count === undefined) return input + const next = new Map(input) + if (count <= 1) next.delete(sessionID) + else next.set(sessionID, count - 1) + return next +} + /** * Makes one scoped, process-local registry. Entries are intentionally not * durable: process restart or owner-scope closure loses status and interrupts @@ -123,26 +139,13 @@ export const make = Effect.gen(function* () { scope: yield* Scope.Scope, } - const settle = Effect.fn("BackgroundJob.settle")(function* ( - id: string, - token: object, - sequence: number, - exit: Exit.Exit, - ) { + const settle = Effect.fn("Job.settle")(function* (id: string, token: object, exit: Exit.Exit) { const completed_at = yield* Clock.currentTimeMillis const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map] => { const job = jobs.get(id) if (!job) return [{}, jobs] if (job.token !== token) return [{}, jobs] if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs] - const pending = job.pending - 1 - const output = - Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence) - ? { sequence, text: exit.value } - : job.output - if (Exit.isSuccess(exit) && pending > 0) { - return [{}, new Map(jobs).set(id, { ...job, pending, output })] - } const status: Exclude = Exit.isSuccess(exit) ? "completed" : Cause.hasInterruptsOnly(exit.cause) @@ -150,14 +153,12 @@ export const make = Effect.gen(function* () { : "error" const next = { ...job, - onPromote: undefined, - pending: 0, - output, + blockingSessions: new Map(), info: { ...job.info, status, completed_at, - ...(output ? { output: output.text } : {}), + ...(Exit.isSuccess(exit) ? { output: exit.value } : {}), ...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}), }, } @@ -170,43 +171,41 @@ export const make = Effect.gen(function* () { return result.info }) - const fork = Effect.fn("BackgroundJob.fork")(function* ( + const fork = Effect.fn("Job.fork")(function* ( scope: Scope.Scope, id: string, token: object, - sequence: number, run: Effect.Effect, ) { return yield* run.pipe( Effect.matchCauseEffect({ - onSuccess: (output) => settle(id, token, sequence, Exit.succeed(output)), - onFailure: (cause) => settle(id, token, sequence, Exit.failCause(cause)), + onSuccess: (output) => settle(id, token, Exit.succeed(output)), + onFailure: (cause) => settle(id, token, Exit.failCause(cause)), }), Effect.asVoid, Effect.forkIn(scope, { startImmediately: true }), ) }) - const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () { + const list: Interface["list"] = Effect.fn("Job.list")(function* () { return Array.from((yield* SynchronizedRef.get(state.jobs)).values()) .map(snapshot) .toSorted((a, b) => a.started_at - b.started_at) }) - const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) { + const get: Interface["get"] = Effect.fn("Job.get")(function* (id) { const job = (yield* SynchronizedRef.get(state.jobs)).get(id) - if (!job) return + if (!job) return undefined return snapshot(job) }) - const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) { + const start: Interface["start"] = Effect.fn("Job.start")(function* (input) { return yield* Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const id = input.id ?? Identifier.ascending("job") const started_at = yield* Clock.currentTimeMillis const done = yield* Deferred.make() - const promoted = yield* Deferred.make() - const tail = yield* Deferred.make() + const backgrounded = yield* Deferred.make() const result = yield* SynchronizedRef.modifyEffect( state.jobs, Effect.fnUntraced(function* (jobs) { @@ -226,13 +225,11 @@ export const make = Effect.gen(function* () { metadata: input.metadata, }, done, + backgrounded, scope, token, - pending: 1, - next: 1, - tail, - promoted, - onPromote: input.onPromote, + blockingSessions: new Map(), + isBackgrounded: false, } return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [ StartResult, @@ -240,56 +237,13 @@ export const make = Effect.gen(function* () { ] }), ) - if ("scope" in result) - yield* fork( - result.scope, - id, - result.token, - 0, - restore(input.run).pipe(Effect.ensuring(Deferred.succeed(tail, undefined))), - ) + if ("scope" in result) yield* fork(result.scope, id, result.token, restore(input.run)) return result.info }), ) }) - const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) { - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const tail = yield* Deferred.make() - const result = yield* SynchronizedRef.modify( - state.jobs, - (jobs): readonly [ExtendResult, Map] => { - const job = jobs.get(input.id) - if (!job || job.info.status !== "running") return [{ extended: false }, jobs] - return [ - { extended: true, previous: job.tail, scope: job.scope, tail, token: job.token, sequence: job.next }, - new Map(jobs).set(input.id, { - ...job, - pending: job.pending + 1, - next: job.next + 1, - tail, - }), - ] - }, - ) - if (!result.extended) return false - yield* fork( - result.scope, - input.id, - result.token, - result.sequence, - Deferred.await(result.previous).pipe( - Effect.andThen(restore(input.run)), - Effect.ensuring(Deferred.succeed(result.tail, undefined)), - ), - ) - return true - }), - ) - }) - - const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) { + const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) { const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id) if (!job) return { timedOut: false } if (job.info.status !== "running") return { info: snapshot(job), timedOut: false } @@ -300,41 +254,91 @@ export const make = Effect.gen(function* () { return { info: snapshot(job), timedOut: true } }) - const waitForPromotion: Interface["waitForPromotion"] = Effect.fn("BackgroundJob.waitForPromotion")(function* (id) { - const job = (yield* SynchronizedRef.get(state.jobs)).get(id) - if (!job || job.info.status !== "running") return yield* Effect.never - if (job.info.metadata?.background === true) return snapshot(job) - return yield* Deferred.await(job.promoted) + const removeBlock = Effect.fn("Job.removeBlock")(function* (input: BlockInput) { + yield* SynchronizedRef.update(state.jobs, (jobs) => { + const job = jobs.get(input.id) + if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs + return new Map(jobs).set(input.id, { + ...job, + blockingSessions: decrementSession(job.blockingSessions, input.sessionID), + }) + }) }) - const promote: Interface["promote"] = Effect.fn("BackgroundJob.promote")(function* (id) { - const result = yield* SynchronizedRef.modifyEffect( + const block: Interface["block"] = Effect.fn("Job.block")(function* (input) { + const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map] => { + const job = jobs.get(input.id) + if (!job) return [{ type: "missing" }, jobs] + if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs] + if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs] + return [ + { type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } }, + new Map(jobs).set(input.id, { + ...job, + blockingSessions: incrementSession(job.blockingSessions, input.sessionID), + }), + ] + }) + if (result.type === "missing") return undefined + if (result.type === "finished") return { type: "finished", info: result.info } + if (result.type === "backgrounded") return { type: "backgrounded", info: result.info } + return yield* Effect.raceFirst( + Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))), + Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))), + ).pipe(Effect.ensuring(removeBlock(input))) + }) + + const background: Interface["background"] = Effect.fn("Job.background")(function* (id) { + const result = yield* SynchronizedRef.modify( state.jobs, - Effect.fnUntraced(function* (jobs) { + (jobs): readonly [BackgroundResult, Map] => { const job = jobs.get(id) - if (!job || job.info.status !== "running") return [{}, jobs] as readonly [PromoteResult, Map] - if (job.info.metadata?.background === true) - return [{ info: snapshot(job) }, jobs] as readonly [PromoteResult, Map] + if (!job || job.info.status !== "running") return [{}, jobs] + if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs] const next = { ...job, - onPromote: undefined, - info: { - ...job.info, - metadata: { ...job.info.metadata, background: true }, - }, + isBackgrounded: true, + blockingSessions: new Map(), } - return [ - { info: snapshot(next), onPromote: job.onPromote, promoted: job.promoted }, - new Map(jobs).set(id, next), - ] as readonly [PromoteResult, Map] - }), + return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)] + }, ) - if (result.info && result.promoted) yield* Deferred.succeed(result.promoted, result.info).pipe(Effect.ignore) - if (result.onPromote) yield* result.onPromote.pipe(Effect.ignore) + if (result.info && result.backgrounded) + yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore) return result.info }) - const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) { + const backgroundAll: Interface["backgroundAll"] = Effect.fn("Job.backgroundAll")(function* (input) { + const result = yield* SynchronizedRef.modify( + state.jobs, + (jobs): readonly [BackgroundResult[], Map] => { + const results: BackgroundResult[] = [] + const next = new Map(jobs) + for (const [id, job] of jobs) { + if (job.info.status !== "running") continue + if (job.isBackgrounded) continue + if (input.type !== undefined && job.info.type !== input.type) continue + if (!job.blockingSessions.has(input.sessionID)) continue + const updated = { + ...job, + isBackgrounded: true, + blockingSessions: new Map(), + } + results.push({ info: snapshot(updated), backgrounded: job.backgrounded }) + next.set(id, updated) + } + return [results, next] + }, + ) + yield* Effect.forEach( + result, + (item) => (item.info && item.backgrounded ? Deferred.succeed(item.backgrounded, item.info) : Effect.void), + { discard: true }, + ) + return result.flatMap((item) => (item.info ? [item.info] : [])) + }) + + const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) { const completed_at = yield* Clock.currentTimeMillis const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map] => { const job = jobs.get(id) @@ -342,8 +346,7 @@ export const make = Effect.gen(function* () { if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs] const next = { ...job, - onPromote: undefined, - pending: 0, + blockingSessions: new Map(), info: { ...job.info, status: "cancelled" as const, @@ -357,7 +360,7 @@ export const make = Effect.gen(function* () { return result.info }) - return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel }) + return Service.of({ list, get, start, wait, block, background, backgroundAll, cancel }) }) export const layer = Layer.effect(Service, make) diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index 9371a51d4f..b56438a80b 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -3,8 +3,8 @@ export * as ShellTool from "./shell" import path from "path" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema, Scope } from "effect" -import { BackgroundJob } from "../background-job" import { FSUtil } from "../fs-util" +import { Job } from "../job" import { LocationMutation } from "../location-mutation" import { LocationServiceMap } from "../location-service-map" import { PermissionV2 } from "../permission" @@ -74,8 +74,8 @@ const modelOutput = (output: Output): string | undefined => { // TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows. // TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist. // TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired. -// TODO: Persist background job status and define restart recovery before exposing remote observation. -// TODO: Add HTTP background-job observation only after durable status, restart recovery, and authorization are defined. +// TODO: Persist job status and define restart recovery before exposing remote observation. +// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined. // TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it. // TODO: Revisit binary output handling if stdout/stderr decoding is text-only. // TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview. @@ -98,12 +98,12 @@ export const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* ApplicationTools.Service const sessions = yield* SessionV2.Service - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const locations = yield* LocationServiceMap.Service const scope = yield* Scope.Scope const fsUtil = yield* FSUtil.Service - const injectWhenDone = Effect.fn("ShellTool.injectWhenDone")(function* ( + const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* ( sessionID: SessionSchema.ID, callID: string, command: string, @@ -121,9 +121,9 @@ export const layer = Layer.effectDiscard( if (state === undefined) return Effect.void const text = state === "completed" - ? result.info!.output ?? "" + ? (result.info!.output ?? "") : state === "error" - ? result.info!.error ?? "Command failed" + ? (result.info!.error ?? "Command failed") : "Command cancelled" return sessions.synthetic({ sessionID, @@ -156,9 +156,7 @@ export const layer = Layer.effectDiscard( Effect.gen(function* () { const parent = yield* sessions .get(context.sessionID) - .pipe( - Effect.mapError(() => new ToolFailure({ message: `Session not found: ${context.sessionID}` })), - ) + .pipe(Effect.mapError(() => new ToolFailure({ message: `Session not found: ${context.sessionID}` }))) return yield* Effect.gen(function* () { const mutation = yield* LocationMutation.Service const shell = yield* Shell.Service @@ -203,16 +201,18 @@ export const layer = Layer.effectDiscard( timeout, metadata: { sessionID: context.sessionID }, }) - const final = yield* shell.wait(info.id) - const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) + return yield* Effect.gen(function* () { + const final = yield* shell.wait(info.id) + const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES }) - if (final.status === "timeout") - return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.` + if (final.status === "timeout") + return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.` - const truncated = page.size > page.cursor - const body = page.output || "(no output)" - const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" - return `${body}${notice}` + const truncated = page.size > page.cursor + const body = page.output || "(no output)" + const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : "" + return `${body}${notice}` + }).pipe(Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore))) }) const info = yield* jobs.start({ @@ -220,10 +220,10 @@ export const layer = Layer.effectDiscard( type: name, title: input.command, metadata: { sessionID: context.sessionID }, - onPromote: injectWhenDone(context.sessionID, context.toolCallID, input.command), run: run(), }) - yield* injectWhenDone(context.sessionID, context.toolCallID, input.command) + yield* jobs.background(info.id) + yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) return { output: BACKGROUND_STARTED, truncated: false, @@ -262,7 +262,7 @@ export const layer = Layer.effectDiscard( status: "completed" as const, ...(warnings.length ? { warnings } : {}), } - }).pipe(Effect.provide(locations.get(parent.location))) as Effect.Effect, ToolFailure> + }).pipe(Effect.provide(locations.get(parent.location))) }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), }), }) @@ -273,5 +273,5 @@ export const layer = Layer.effectDiscard( export const node = makeGlobalNode({ name: "shell-tool", layer, - deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, LocationServiceMap.node, FSUtil.node], + deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node, FSUtil.node], }) diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index f5e8ebcd84..e32987aad3 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -3,7 +3,7 @@ export * as SubagentTool from "./subagent" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema, Scope } from "effect" import { AgentV2 } from "../agent" -import { BackgroundJob } from "../background-job" +import { Job } from "../job" import { LocationServiceMap } from "../location-service-map" import { SessionV2 } from "../session" import { SessionSchema } from "../session/schema" @@ -44,7 +44,7 @@ export const layer = Layer.effectDiscard( Effect.gen(function* () { const tools = yield* ApplicationTools.Service const sessions = yield* SessionV2.Service - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const locations = yield* LocationServiceMap.Service const scope = yield* Scope.Scope @@ -77,7 +77,7 @@ export const layer = Layer.effectDiscard( }) }) - const injectWhenDone = Effect.fn("SubagentTool.injectWhenDone")(function* ( + const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* ( parentID: SessionSchema.ID, childID: SessionSchema.ID, description: string, @@ -138,37 +138,37 @@ export const layer = Layer.effectDiscard( yield* sessions.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false }) yield* sessions.resume(child.id) return yield* latestAssistantText(child.id) - }) + }).pipe(Effect.onInterrupt(() => sessions.interrupt(child.id))) const info = yield* jobs.start({ id: child.id, type: name, title: input.description, metadata: {}, - onPromote: injectWhenDone(context.sessionID, child.id, input.description), run, }) if (background) { - if ((yield* jobs.promote(info.id)) === undefined) - yield* injectWhenDone(context.sessionID, child.id, input.description) + yield* jobs.background(info.id) + yield* notifyWhenDone(context.sessionID, child.id, input.description) return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } } - const result = yield* Effect.raceFirst( - jobs.wait({ id: child.id }).pipe(Effect.map((waited) => waited.info)), - jobs.waitForPromotion(child.id), - ).pipe( - Effect.onInterrupt(() => - Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }), - ), - ) - if (result?.metadata?.background === true) + const result = yield* jobs + .block({ id: child.id, sessionID: context.sessionID }) + .pipe( + Effect.onInterrupt(() => + Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }), + ), + ) + if (result?.type === "backgrounded") { + yield* notifyWhenDone(context.sessionID, child.id, input.description) return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED } - if (result?.status === "error") - return yield* new ToolFailure({ message: result.error ?? "Subagent failed" }) - if (result?.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" }) - return { sessionID: child.id, status: "completed" as const, output: result?.output ?? NO_TEXT } + } + if (result?.info.status === "error") + return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" }) + if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" }) + return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT } }), }), }) @@ -182,5 +182,5 @@ export const layer = Layer.effectDiscard( export const node = makeGlobalNode({ name: "subagent-tool", layer, - deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, LocationServiceMap.node], + deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node], }) diff --git a/packages/core/test/background-job.test.ts b/packages/core/test/background-job.test.ts deleted file mode 100644 index 1c4f93f019..0000000000 --- a/packages/core/test/background-job.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, expect } from "bun:test" -import { BackgroundJob } from "@opencode-ai/core/background-job" -import { Deferred, Effect, Exit, Scope } from "effect" -import { it } from "./lib/effect" - -describe("BackgroundJob", () => { - it.live("tracks process-local work through explicit observation", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const latch = yield* Deferred.make() - const job = yield* jobs.start({ - type: "test", - metadata: { durable: false }, - run: Deferred.await(latch).pipe(Effect.as("done")), - }) - - expect(job).toMatchObject({ type: "test", status: "running", metadata: { durable: false } }) - expect(yield* jobs.wait({ id: job.id, timeout: 0 })).toMatchObject({ - timedOut: true, - info: { status: "running" }, - }) - - yield* Deferred.succeed(latch, undefined) - expect(yield* jobs.wait({ id: job.id })).toMatchObject({ - timedOut: false, - info: { status: "completed", output: "done" }, - }) - }).pipe(Effect.provide(BackgroundJob.layer)), - ) - - it.live("publishes jobs before starting immediately settling work", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - - yield* Effect.forEach(Array.from({ length: 100 }), (_, index) => { - const id = `job_immediate_start_${index}` - return Effect.gen(function* () { - const job = yield* jobs.start({ - id, - type: "test", - run: jobs - .get(id) - .pipe( - Effect.flatMap((info) => - info?.status === "running" - ? Effect.succeed(`done-${index}`) - : Effect.fail("job started before publish"), - ), - ), - }) - - expect(yield* jobs.wait({ id: job.id })).toMatchObject({ - timedOut: false, - info: { status: "completed", output: `done-${index}` }, - }) - }) - }) - }).pipe(Effect.provide(BackgroundJob.layer)), - ) - - it.live("increments pending work before starting immediately settling extensions", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - - yield* Effect.forEach(Array.from({ length: 100 }), (_, index) => - Effect.gen(function* () { - const first = yield* Deferred.make() - const job = yield* jobs.start({ - type: "test", - run: Deferred.await(first).pipe(Effect.as(`first-${index}`)), - }) - - expect(yield* jobs.extend({ id: job.id, run: Effect.succeed(`second-${index}`) })).toBe(true) - expect((yield* jobs.get(job.id))?.status).toBe("running") - - yield* Deferred.succeed(first, undefined) - expect(yield* jobs.wait({ id: job.id })).toMatchObject({ - timedOut: false, - info: { status: "completed", output: `second-${index}` }, - }) - }), - ) - }).pipe(Effect.provide(BackgroundJob.layer)), - ) - - it.live("interrupts live work without promising settlement after the owning process-local scope closes", () => - Effect.gen(function* () { - const scope = yield* Scope.make() - const interrupted = yield* Deferred.make() - const jobs = yield* BackgroundJob.make.pipe(Scope.provide(scope)) - const job = yield* jobs.start({ - type: "test", - run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))), - }) - - yield* Scope.close(scope, Exit.void) - - yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second")) - // The abandoned in-memory registry is not a durable observation channel. - expect((yield* jobs.get(job.id))?.status).toBe("running") - }), - ) -}) diff --git a/packages/core/test/job.test.ts b/packages/core/test/job.test.ts new file mode 100644 index 0000000000..8c35d51210 --- /dev/null +++ b/packages/core/test/job.test.ts @@ -0,0 +1,164 @@ +import { describe, expect } from "bun:test" +import { Job } from "@opencode-ai/core/job" +import { Deferred, Effect, Exit, Fiber, Scope } from "effect" +import { SessionSchema } from "@opencode-ai/core/session/schema" +import { testEffect } from "./lib/effect" + +const it = testEffect(Job.layer) + +describe("Job", () => { + it.live("tracks process-local work through explicit observation", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const latch = yield* Deferred.make() + const job = yield* jobs.start({ + type: "test", + metadata: { durable: false }, + run: Deferred.await(latch).pipe(Effect.as("done")), + }) + + expect(job).toMatchObject({ type: "test", status: "running", metadata: { durable: false } }) + expect(yield* jobs.wait({ id: job.id, timeout: 0 })).toMatchObject({ + timedOut: true, + info: { status: "running" }, + }) + + yield* Deferred.succeed(latch, undefined) + expect(yield* jobs.wait({ id: job.id })).toMatchObject({ + timedOut: false, + info: { status: "completed", output: "done" }, + }) + }), + ) + + it.live("publishes jobs before starting immediately settling work", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + + yield* Effect.forEach(Array.from({ length: 100 }), (_, index) => { + const id = `job_immediate_start_${index}` + return Effect.gen(function* () { + const job = yield* jobs.start({ + id, + type: "test", + run: jobs + .get(id) + .pipe( + Effect.flatMap((info) => + info?.status === "running" + ? Effect.succeed(`done-${index}`) + : Effect.fail("job started before publish"), + ), + ), + }) + + expect(yield* jobs.wait({ id: job.id })).toMatchObject({ + timedOut: false, + info: { status: "completed", output: `done-${index}` }, + }) + }) + }) + }), + ) + + it.live("returns finished from a blocking wait when completion wins", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const latch = yield* Deferred.make() + const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) }) + const waiting = yield* jobs + .block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") }) + .pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true })) + + yield* Deferred.succeed(latch, undefined) + + expect(yield* Fiber.join(waiting)).toMatchObject({ + type: "finished", + info: { status: "completed", output: "done" }, + }) + expect(yield* jobs.background(job.id)).toBeUndefined() + }), + ) + + it.live("returns backgrounded from a blocking wait when background wins", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const latch = yield* Deferred.make() + const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) }) + const waiting = yield* jobs + .block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") }) + .pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true })) + + expect(yield* jobs.background(job.id)).toMatchObject({ id: job.id, status: "running" }) + expect(yield* Fiber.join(waiting)).toMatchObject({ + type: "backgrounded", + info: { id: job.id, status: "running" }, + }) + + yield* Deferred.succeed(latch, undefined) + expect(yield* jobs.wait({ id: job.id })).toMatchObject({ + timedOut: false, + info: { status: "completed", output: "done" }, + }) + }), + ) + + it.live("backgrounds only jobs actively blocking a session", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const parent = SessionSchema.ID.make("ses_parent") + const other = SessionSchema.ID.make("ses_other") + const latch = yield* Deferred.make() + const first = yield* jobs.start({ + id: "job_first", + type: "test", + run: Deferred.await(latch).pipe(Effect.as("first")), + }) + const second = yield* jobs.start({ + id: "job_second", + type: "test", + run: Deferred.await(latch).pipe(Effect.as("second")), + }) + const third = yield* jobs.start({ + id: "job_third", + type: "other", + run: Deferred.await(latch).pipe(Effect.as("third")), + }) + const scope = yield* Scope.Scope + const firstWait = yield* jobs + .block({ id: first.id, sessionID: parent }) + .pipe(Effect.forkIn(scope, { startImmediately: true })) + const secondWait = yield* jobs + .block({ id: second.id, sessionID: other }) + .pipe(Effect.forkIn(scope, { startImmediately: true })) + const thirdWait = yield* jobs + .block({ id: third.id, sessionID: parent }) + .pipe(Effect.forkIn(scope, { startImmediately: true })) + + expect(yield* jobs.backgroundAll({ sessionID: parent, type: "test" })).toMatchObject([{ id: first.id }]) + expect(yield* Fiber.join(firstWait)).toMatchObject({ type: "backgrounded", info: { id: first.id } }) + + yield* Deferred.succeed(latch, undefined) + expect(yield* Fiber.join(secondWait)).toMatchObject({ type: "finished", info: { id: second.id } }) + expect(yield* Fiber.join(thirdWait)).toMatchObject({ type: "finished", info: { id: third.id } }) + }), + ) + + it.live("interrupts live work without promising settlement after the owning process-local scope closes", () => + Effect.gen(function* () { + const scope = yield* Scope.make() + const interrupted = yield* Deferred.make() + const jobs = yield* Job.make.pipe(Scope.provide(scope)) + const job = yield* jobs.start({ + type: "test", + run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))), + }) + + yield* Scope.close(scope, Exit.void) + + yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second")) + // The abandoned in-memory registry is not a durable observation channel. + expect((yield* jobs.get(job.id))?.status).toBe("running") + }), + ) +}) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index d9243ad0c5..42617c8f75 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -17,12 +17,11 @@ import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { AgentV2 } from "@opencode-ai/core/agent" -import { BackgroundJob } from "@opencode-ai/core/background-job" +import { Job } from "@opencode-ai/core/job" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionMessage } from "@opencode-ai/core/session/message" -import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionStore } from "@opencode-ai/core/session/store" import { PermissionV2 } from "@opencode-ai/core/permission" import { ShellTool } from "@opencode-ai/core/tool/shell" @@ -120,7 +119,7 @@ const layer = AppNodeBuilder.build( LayerNode.group([ Database.node, EventV2.node, - BackgroundJob.node, + Job.node, ToolOutputStore.cleanupNode, SessionV2.node, ShellTool.node, @@ -155,10 +154,7 @@ const overflowCommand = (bytes: number) => ? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100` : `head -c ${bytes} /dev/zero | tr '\\0' 'x'` -const withSession = ( - directory: string, - body: (registry: ToolRegistry.Interface) => Effect.Effect, -) => +const withSession = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => Effect.gen(function* () { const sessions = yield* SessionV2.Service const location = Location.Ref.make({ directory: AbsolutePath.make(directory) }) @@ -214,9 +210,7 @@ describe("ShellTool", () => { reset() return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe( Effect.andThen( - withSession(tmp.path, (registry) => - settleTool(registry, call({ command: cwdCommand, workdir: "src" })), - ), + withSession(tmp.path, (registry) => settleTool(registry, call({ command: cwdCommand, workdir: "src" }))), ), Effect.andThen((settled) => Effect.sync(() => @@ -247,9 +241,7 @@ describe("ShellTool", () => { : Effect.void return Effect.promise(() => fs.mkdir(workdir)).pipe( Effect.andThen( - withSession(tmp.path, (registry) => - executeTool(registry, call({ command: cwdCommand, workdir: "src" })), - ), + withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))), ), Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))), ) @@ -314,9 +306,7 @@ describe("ShellTool", () => { reset() denyAction = "external_directory" const target = path.join(outside.path, "secret.txt") - return withSession(active.path, (registry) => - settleTool(registry, call({ command: `cat ${target}` })), - ).pipe( + return withSession(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe( Effect.andThen((settled) => Effect.sync(() => { expect(assertions.map((item) => item.action)).toEqual(["shell"]) @@ -417,7 +407,7 @@ test("keeps locked deferred parity TODOs visible", async () => { "Restore PowerShell and cmd-specific invocation/path handling on Windows.", "Add plugin shell.env environment augmentation once V2 plugin hooks exist.", "Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.", - "Persist background job status and define restart recovery before exposing remote observation.", + "Persist job status and define restart recovery before exposing remote observation.", "Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.", "Revisit binary output handling if stdout/stderr decoding is text-only.", "Stream full shell output into managed storage while retaining only a bounded in-memory preview.", diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index ae50ecc9d5..70dd693244 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -10,7 +10,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { AgentV2 } from "@opencode-ai/core/agent" -import { BackgroundJob } from "@opencode-ai/core/background-job" +import { Job } from "@opencode-ai/core/job" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { SessionV2 } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -95,7 +95,7 @@ const layer = AppNodeBuilder.build( LayerNode.group([ Database.node, EventV2.node, - BackgroundJob.node, + Job.node, ToolOutputStore.cleanupNode, SessionV2.node, SubagentTool.node, @@ -242,7 +242,7 @@ describe("SubagentTool", () => { ), ) - it.live("promotes background work and injects one synthetic parent completion", () => + it.live("notifies once when background work completes", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), @@ -251,7 +251,6 @@ describe("SubagentTool", () => { Effect.gen(function* () { const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) const sessions = yield* SessionV2.Service - const jobs = yield* BackgroundJob.Service const parent = yield* sessions.create({ location }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service @@ -270,7 +269,6 @@ describe("SubagentTool", () => { const childID = outputSessionID(settled.output?.structured) expect(settled.output?.structured).toMatchObject({ status: "running" }) - yield* jobs.promote(childID) yield* Effect.yieldNow const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic") expect(synthetic).toHaveLength(1) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 30dbb4c880..b6060279e9 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -48,7 +48,7 @@ import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" import { Npm } from "@opencode-ai/core/npm" import { memoMap } from "@opencode-ai/core/effect/memo-map" -import { BackgroundJob } from "@/background/job" +import { Job } from "@/job" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" @@ -74,7 +74,7 @@ export const AppLayer = Layer.mergeAll( Todo.defaultLayer, Session.defaultLayer, SessionStatus.defaultLayer, - BackgroundJob.defaultLayer, + Job.defaultLayer, RuntimeFlags.defaultLayer, EventV2Bridge.defaultLayer, SessionRunState.defaultLayer, diff --git a/packages/opencode/src/background/job.ts b/packages/opencode/src/job.ts similarity index 55% rename from packages/opencode/src/background/job.ts rename to packages/opencode/src/job.ts index 8095826efc..0e8ea7825a 100644 --- a/packages/opencode/src/background/job.ts +++ b/packages/opencode/src/job.ts @@ -1,32 +1,34 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { BackgroundJob as CoreBackgroundJob } from "@opencode-ai/core/background-job" +import { Service, make } from "@opencode-ai/core/job" import { InstanceState } from "@/effect/instance-state" import { Effect, Layer } from "effect" export { Service, - type ExtendInput, + type BackgroundAllInput, + type BlockInput, + type BlockResult, type Info, type Interface, type StartInput, type Status, type WaitInput, type WaitResult, -} from "@opencode-ai/core/background-job" +} from "@opencode-ai/core/job" /** Keeps the legacy service instance-scoped while sharing the core registry engine. */ export const layer = Layer.effect( - CoreBackgroundJob.Service, + Service, Effect.gen(function* () { - const state = yield* InstanceState.make(() => CoreBackgroundJob.make) - return CoreBackgroundJob.Service.of({ + const state = yield* InstanceState.make(() => make) + return Service.of({ list: () => InstanceState.useEffect(state, (jobs) => jobs.list()), get: (id) => InstanceState.useEffect(state, (jobs) => jobs.get(id)), start: (input) => InstanceState.useEffect(state, (jobs) => jobs.start(input)), - extend: (input) => InstanceState.useEffect(state, (jobs) => jobs.extend(input)), wait: (input) => InstanceState.useEffect(state, (jobs) => jobs.wait(input)), - waitForPromotion: (id) => InstanceState.useEffect(state, (jobs) => jobs.waitForPromotion(id)), - promote: (id) => InstanceState.useEffect(state, (jobs) => jobs.promote(id)), + block: (input) => InstanceState.useEffect(state, (jobs) => jobs.block(input)), + background: (id) => InstanceState.useEffect(state, (jobs) => jobs.background(id)), + backgroundAll: (input) => InstanceState.useEffect(state, (jobs) => jobs.backgroundAll(input)), cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)), }) }), @@ -34,6 +36,6 @@ export const layer = Layer.effect( export const defaultLayer = layer -export const node = LayerNode.make({ service: CoreBackgroundJob.Service, layer, deps: [] }) +export const node = LayerNode.make({ service: Service, layer, deps: [] }) -export * as BackgroundJob from "./job" +export * as Job from "./job" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index caed544005..1433c2041a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -1,6 +1,6 @@ import { Account } from "@/account/account" import { Agent } from "@/agent/agent" -import { BackgroundJob } from "@/background/job" +import { Job } from "@/job" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -33,7 +33,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const registry = yield* ToolRegistry.Service const worktreeSvc = yield* Worktree.Service const sessions = yield* Session.Service - const background = yield* BackgroundJob.Service + const jobs = yield* Job.Service const flags = yield* RuntimeFlags.Service const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () { @@ -159,15 +159,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper params: { sessionID: SessionID } }) { if (!flags.experimentalBackgroundSubagents) return false - const jobs = (yield* background.list()).filter( - (job) => - job.type === "task" && - job.status === "running" && - job.metadata?.parentSessionId === ctx.params.sessionID && - job.metadata.background !== true, - ) - const promoted = yield* Effect.forEach(jobs, (job) => background.promote(job.id), { concurrency: "unbounded" }) - return promoted.some((job) => job !== undefined) + return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0 }) const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () { diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index c3c253812f..1795c2b4da 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -7,7 +7,7 @@ import * as Observability from "@opencode-ai/core/observability" import { Account } from "@/account/account" import { Agent } from "@/agent/agent" import { Auth } from "@/auth" -import { BackgroundJob } from "@/background/job" +import { Job } from "@/job" import { Command } from "@/command" import { Config } from "@/config/config" import { Workspace } from "@/control-plane/workspace" @@ -233,7 +233,7 @@ const app = LayerNode.group([ Session.node, SessionProjector.node, SessionStatus.node, - BackgroundJob.node, + Job.node, RuntimeFlags.node, EventV2Bridge.node, SessionRunState.node, diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 17b0efaeaa..f37ceff97e 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -2,7 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceState } from "@/effect/instance-state" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Runner } from "@/effect/runner" -import { BackgroundJob } from "@/background/job" +import { Job } from "@/job" import { Effect, Latch, Layer, Scope, Context } from "effect" import { Session } from "./session" import { SessionID } from "./schema" @@ -29,7 +29,7 @@ export class Service extends Context.Service()("@opencode/Se export const layer = Layer.effect( Service, Effect.gen(function* () { - const background = yield* BackgroundJob.Service + const jobs = yield* Job.Service const status = yield* SessionStatus.Service const state = yield* InstanceState.make( @@ -75,7 +75,7 @@ export const layer = Layer.effect( }) const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) { - yield* cancelBackgroundJobs(background, sessionID) + yield* cancelJobs(jobs, sessionID) const data = yield* InstanceState.get(state) const existing = data.runners.get(sessionID) if (!existing) { @@ -108,31 +108,25 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), -) +export const defaultLayer = layer.pipe(Layer.provide(Job.defaultLayer), Layer.provide(SessionStatus.defaultLayer)) -const cancelBackgroundJobs = Effect.fn("SessionRunState.cancelBackgroundJobs")(function* ( - background: BackgroundJob.Interface, - sessionID: SessionID, -) { - const jobs = yield* background.list() +const cancelJobs = Effect.fn("SessionRunState.cancelJobs")(function* (jobs: Job.Interface, sessionID: SessionID) { + const running = yield* jobs.list() const pending = new Set([sessionID]) const cancelled = new Set() - const matches = (job: BackgroundJob.Info) => { + const matches = (job: Job.Info) => { if (job.status !== "running") return false if (cancelled.has(job.id)) return false if (pending.has(job.id)) return true if (typeof job.metadata?.sessionId === "string" && pending.has(job.metadata.sessionId)) return true return typeof job.metadata?.parentSessionId === "string" && pending.has(job.metadata.parentSessionId) } - let batch = jobs.filter(matches) + let batch = running.filter(matches) while (batch.length > 0) { yield* Effect.forEach( batch, (job) => - background.cancel(job.id).pipe( + jobs.cancel(job.id).pipe( Effect.tap(() => Effect.sync(() => { cancelled.add(job.id) @@ -143,7 +137,7 @@ const cancelBackgroundJobs = Effect.fn("SessionRunState.cancelBackgroundJobs")(f ), { concurrency: "unbounded", discard: true }, ) - batch = jobs.filter(matches) + batch = running.filter(matches) } }) @@ -151,6 +145,6 @@ function busyError(sessionID: SessionID) { return new Session.BusyError({ sessionID }) } -export const node = LayerNode.make({ service: Service, layer: layer, deps: [BackgroundJob.node, SessionStatus.node] }) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [Job.node, SessionStatus.node] }) export * as SessionRunState from "./run-state" diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f4edf50ed6..1d3252bb09 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -4,7 +4,7 @@ import { Slug } from "@opencode-ai/core/util/slug" import { SessionV1 } from "@opencode-ai/core/v1/session" import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "path" -import { BackgroundJob } from "@/background/job" +import { Job } from "@/job" import { Decimal } from "decimal.js" import type { ProviderMetadata, Usage } from "@opencode-ai/llm" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -491,13 +491,13 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" export const layer: Layer.Layer< Service, never, - BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service + Job.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service > = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service const database = yield* Database.Service - const background = yield* BackgroundJob.Service + const jobs = yield* Job.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service @@ -618,7 +618,7 @@ export const layer: Layer.Layer< Effect.catchCause(() => Effect.succeed(false)), ) - if (hasInstance) yield* cancelBackgroundJobs(background, sessionID) + if (hasInstance) yield* cancelJobs(jobs, sessionID) const kids = yield* children(sessionID) for (const child of kids) { yield* remove(child.id) @@ -941,7 +941,7 @@ export const layer: Layer.Layer< ) export const defaultLayer = layer.pipe( - Layer.provide(BackgroundJob.defaultLayer), + Layer.provide(Job.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer), Layer.provide( @@ -953,19 +953,16 @@ export const defaultLayer = layer.pipe( Layer.provide(RuntimeFlags.defaultLayer), ) -const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function* ( - background: BackgroundJob.Interface, - sessionID: SessionID, -) { - const jobs = yield* background.list() +const cancelJobs = Effect.fn("Session.cancelJobs")(function* (jobs: Job.Interface, sessionID: SessionID) { + const running = yield* jobs.list() yield* Effect.forEach( - jobs.filter((job) => { + running.filter((job) => { if (job.status !== "running") return false if (job.id === sessionID) return true if (job.metadata?.sessionId === sessionID) return true return job.metadata?.parentSessionId === sessionID }), - (job) => background.cancel(job.id), + (job) => jobs.cancel(job.id), { concurrency: "unbounded", discard: true }, ) }) @@ -1098,7 +1095,7 @@ export function* listGlobal(input?: { export const node = LayerNode.make({ service: Service, layer: layer, - deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node], + deps: [Job.node, RuntimeFlags.node, Database.node, EventV2Bridge.node], }) export * as Session from "./session" diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 553fa1ebaf..2b64d24eee 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -48,7 +48,7 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { Agent } from "../agent/agent" import { Skill } from "../skill" import { Permission } from "@/permission" -import { BackgroundJob } from "@/background/job" +import { Job } from "@/job" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -325,7 +325,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Skill.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Session.defaultLayer), - Layer.provide(BackgroundJob.defaultLayer), + Layer.provide(Job.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(LSP.defaultLayer), Layer.provide(Instruction.defaultLayer), @@ -426,7 +426,7 @@ export const node = LayerNode.make({ Agent.node, Skill.node, Session.node, - BackgroundJob.node, + Job.node, Provider.node, LSP.node, Instruction.node, diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index b0a866c90e..f61cea1048 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -2,7 +2,7 @@ import * as Tool from "./tool" import DESCRIPTION from "./task.txt" import { ToolJsonSchema } from "./json-schema" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { BackgroundJob } from "@/background/job" +import { Job } from "@/job" import { Session } from "@/session/session" import { SessionID, MessageID } from "../session/schema" import { MessageV2 } from "../session/message-v2" @@ -33,11 +33,11 @@ const BACKGROUND_STARTED = [ "DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", "Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.", ].join("\n") -const BACKGROUND_UPDATED = [ - "Additional context sent to the running background task.", +const BACKGROUND_ALREADY_RUNNING = [ + "The task is already working in the background.", "The task is still working in the background. You will be notified automatically when it finishes.", "DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.", - "Work on non-overlapping tasks, or briefly tell the user what you sent and end your response.", + "Work on non-overlapping tasks, or briefly tell the user it is still running and end your response.", ].join("\n") const BaseParameterFields = { @@ -82,7 +82,7 @@ export const TaskTool = Tool.define( id, Effect.gen(function* () { const agent = yield* Agent.Service - const background = yield* BackgroundJob.Service + const jobs = yield* Job.Service const config = yield* Config.Service const sessions = yield* Session.Service const scope = yield* Scope.Scope @@ -229,7 +229,7 @@ export const TaskTool = Tool.define( }) const notify = Effect.fn("TaskTool.notifyBackgroundResult")(function* (jobID: string) { - yield* background.wait({ id: jobID }).pipe( + yield* jobs.wait({ id: jobID }).pipe( Effect.flatMap((result) => { if (result.info?.status === "completed") return inject("completed", result.info.output ?? "") if (result.info?.status === "error") return inject("error", result.info.error ?? "") @@ -239,7 +239,8 @@ export const TaskTool = Tool.define( ) }) - if (yield* background.extend({ id: nextSession.id, run: runTask() })) { + const existing = yield* jobs.get(nextSession.id) + if (existing?.status === "running") { return { title: params.description, metadata: { @@ -250,24 +251,17 @@ export const TaskTool = Tool.define( output: renderOutput({ sessionID: nextSession.id, state: "running", - summary: "Background task updated", - text: BACKGROUND_UPDATED, + summary: "Background task already running", + text: BACKGROUND_ALREADY_RUNNING, }), } } - const info = yield* background.start({ + const info = yield* jobs.start({ id: nextSession.id, type: id, title: params.description, metadata, - onPromote: Effect.all([ - ctx.metadata({ - title: params.description, - metadata: { ...metadata, background: true, jobId: nextSession.id }, - }), - notify(nextSession.id), - ]), run: runTask().pipe(Effect.onInterrupt(() => ops.cancel(nextSession.id))), }) @@ -289,6 +283,7 @@ export const TaskTool = Tool.define( } if (runInBackground) { + yield* jobs.background(info.id) yield* notify(info.id) return backgroundResult() } @@ -306,23 +301,27 @@ export const TaskTool = Tool.define( }), () => Effect.gen(function* () { - const result = yield* Effect.raceFirst( - background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)), - background.waitForPromotion(nextSession.id), - ) - if (result?.metadata?.background === true) return backgroundResult() - if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed")) - if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled")) + const result = yield* jobs.block({ id: nextSession.id, sessionID: ctx.sessionID }) + if (result?.type === "backgrounded") { + yield* ctx.metadata({ + title: params.description, + metadata: { ...metadata, background: true, jobId: nextSession.id }, + }) + yield* notify(nextSession.id) + return backgroundResult() + } + if (result?.info.status === "error") + return yield* Effect.fail(new Error(result.info.error ?? "Task failed")) + if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled")) return { title: params.description, metadata, - output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }), + output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.info.output ?? "" }), } }), (_, exit) => Effect.gen(function* () { - if (Exit.hasInterrupts(exit)) - yield* Effect.all([cancel, background.cancel(nextSession.id)], { discard: true }) + if (Exit.hasInterrupts(exit)) yield* Effect.all([cancel, jobs.cancel(nextSession.id)], { discard: true }) }).pipe( Effect.ensuring( Effect.sync(() => { diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index 464b75cd82..33b4e9c143 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -172,7 +172,7 @@ Wait on a **published readiness signal**, not wall-clock time. Available afforda - `awaitWithTimeout(effect, message, duration?)` from `test/lib/effect.ts` — wrap any effect with `Effect.timeoutOrElse` and a custom error message. - `llm.wait(n)` from `test/lib/llm-server.ts` — wait until the mock LLM has received `n` HTTP calls. - `SessionStatus.Service` `.get(sessionID)` — observable per-session state (`{ type: "busy" | "idle" | ... }`). -- `BackgroundJob.wait({ id, timeout })` from `src/background/job.ts` — wait for a background job to complete. +- `Job.wait({ id, timeout })` from `src/job.ts` — wait for a job to complete. - Bus subscriptions — fork `Stream.runForEach(bus.subscribe(Event), ...)` and open a `Latch` inside the callback to signal first-event readiness. - `Deferred.await(deferred).pipe(Effect.timeoutOrElse(...))` for one-shot signals. diff --git a/packages/opencode/test/background/job.test.ts b/packages/opencode/test/background/job.test.ts deleted file mode 100644 index dbcb484dc6..0000000000 --- a/packages/opencode/test/background/job.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { describe, expect } from "bun:test" -import { Deferred, Effect } from "effect" -import { BackgroundJob } from "@/background/job" -import { testEffect } from "../lib/effect" - -const it = testEffect(BackgroundJob.defaultLayer) - -describe("background.job", () => { - it.instance("tracks started jobs through completion", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const latch = yield* Deferred.make() - const job = yield* jobs.start({ - type: "test", - title: "test job", - run: Deferred.await(latch).pipe(Effect.as("done")), - }) - - expect(job.id.startsWith("job_")).toBe(true) - expect(job.status).toBe("running") - expect(job.title).toBe("test job") - - yield* Deferred.succeed(latch, undefined) - const done = yield* jobs.wait({ id: job.id }) - - expect(done.timedOut).toBe(false) - expect(done.info?.status).toBe("completed") - expect(done.info?.output).toBe("done") - expect((yield* jobs.list()).map((item) => item.id)).toEqual([job.id]) - }), - ) - - it.instance("returns a running snapshot when wait times out", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const job = yield* jobs.start({ - type: "test", - run: Effect.never, - }) - - const result = yield* jobs.wait({ id: job.id, timeout: 1 }) - - expect(result.timedOut).toBe(true) - expect(result.info?.status).toBe("running") - }), - ) - - it.instance("deduplicates concurrent starts for a running id", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const started = yield* Deferred.make() - const id = "job_test" - const [first, second] = yield* Effect.all( - [ - jobs.start({ - id, - type: "test", - run: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), - }), - jobs.start({ - id, - type: "test", - run: Effect.fail(new Error("duplicate started")), - }), - ], - { concurrency: "unbounded" }, - ) - - yield* Deferred.await(started) - - expect(first.id).toBe(id) - expect(second.id).toBe(id) - expect(first.status).toBe("running") - expect(second.status).toBe("running") - expect((yield* jobs.list()).map((item) => item.id)).toEqual([id]) - - yield* jobs.cancel(id) - }), - ) - - it.instance("waits for extensions before completing a running job", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const first = yield* Deferred.make() - const second = yield* Deferred.make() - const job = yield* jobs.start({ - type: "test", - run: Deferred.await(first).pipe(Effect.as("first")), - }) - - expect(yield* jobs.extend({ id: job.id, run: Deferred.await(second).pipe(Effect.as("second")) })).toBe(true) - yield* Deferred.succeed(first, undefined) - expect((yield* jobs.get(job.id))?.status).toBe("running") - - yield* Deferred.succeed(second, undefined) - const done = yield* jobs.wait({ id: job.id }) - expect(done.info?.status).toBe("completed") - expect(done.info?.output).toBe("second") - }), - ) - - it.instance("runs extensions after earlier work completes", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const first = yield* Deferred.make() - const order: string[] = [] - const job = yield* jobs.start({ - type: "test", - run: Effect.sync(() => order.push("start")).pipe(Effect.andThen(Deferred.await(first)), Effect.as("first")), - }) - - expect( - yield* jobs.extend({ - id: job.id, - run: Effect.sync(() => order.push("extend")).pipe(Effect.as("second")), - }), - ).toBe(true) - yield* Effect.yieldNow - expect(order).toEqual(["start"]) - - yield* Deferred.succeed(first, undefined) - expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("second") - expect(order).toEqual(["start", "extend"]) - }), - ) - - it.instance("rejects extensions after a job completes", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const job = yield* jobs.start({ type: "test", run: Effect.succeed("done") }) - yield* jobs.wait({ id: job.id }) - - expect(yield* jobs.extend({ id: job.id, run: Effect.succeed("late") })).toBe(false) - expect((yield* jobs.get(job.id))?.output).toBe("done") - }), - ) - - it.instance("records failed jobs", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const job = yield* jobs.start({ - type: "test", - run: Effect.fail(new Error("boom")), - }) - - const result = yield* jobs.wait({ id: job.id }) - - expect(result.info?.status).toBe("error") - expect(result.info?.error).toBe("boom") - }), - ) - - it.instance("ignores stale settlements after restarting a failed job", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const fail = yield* Deferred.make() - const interrupted = yield* Deferred.make() - const release = yield* Deferred.make() - const id = "job_test" - yield* jobs.start({ - id, - type: "test", - run: Deferred.await(fail).pipe(Effect.andThen(Effect.fail(new Error("boom")))), - }) - yield* jobs.extend({ - id, - run: Effect.never.pipe( - Effect.ensuring(Deferred.succeed(interrupted, undefined).pipe(Effect.andThen(Deferred.await(release)))), - ), - }) - - yield* Deferred.succeed(fail, undefined) - expect((yield* jobs.wait({ id })).info?.status).toBe("error") - yield* Deferred.await(interrupted) - yield* jobs.start({ id, type: "test", run: Effect.never }) - - yield* Deferred.succeed(release, undefined) - yield* Effect.yieldNow - expect((yield* jobs.get(id))?.status).toBe("running") - yield* jobs.cancel(id) - }), - ) - - it.instance("can cancel running jobs", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const interrupted = yield* Deferred.make() - const job = yield* jobs.start({ - type: "test", - run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))), - }) - yield* jobs.extend({ - id: job.id, - run: Effect.never, - }) - - const cancelled = yield* jobs.cancel(job.id) - - expect(cancelled?.status).toBe("cancelled") - yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second")) - expect((yield* jobs.get(job.id))?.status).toBe("cancelled") - }), - ) - - it.instance("promotes running jobs without interrupting them", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const latch = yield* Deferred.make() - const promoted = yield* Deferred.make() - const job = yield* jobs.start({ - type: "test", - metadata: { parentSessionId: "parent" }, - onPromote: Deferred.succeed(promoted, undefined).pipe(Effect.asVoid), - run: Deferred.await(latch).pipe(Effect.as("done")), - }) - - const info = yield* jobs.promote(job.id) - - expect(info?.status).toBe("running") - expect(info?.metadata?.background).toBe(true) - yield* Deferred.await(promoted) - expect((yield* jobs.get(job.id))?.status).toBe("running") - - yield* Deferred.succeed(latch, undefined) - expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done") - }), - ) - - it.instance("returns immutable snapshots", () => - Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service - const job = yield* jobs.start({ - type: "test", - metadata: { value: "initial" }, - run: Effect.succeed("done"), - }) - - if (job.metadata) job.metadata.value = "changed" - - expect((yield* jobs.get(job.id))?.metadata?.value).toBe("initial") - }), - ) -}) diff --git a/packages/opencode/test/job.test.ts b/packages/opencode/test/job.test.ts new file mode 100644 index 0000000000..28da9c43bd --- /dev/null +++ b/packages/opencode/test/job.test.ts @@ -0,0 +1,131 @@ +import { describe, expect } from "bun:test" +import { Deferred, Effect, Fiber } from "effect" +import { Job } from "@/job" +import { SessionSchema } from "@opencode-ai/core/session/schema" +import { testEffect } from "./lib/effect" + +const it = testEffect(Job.defaultLayer) + +describe("job", () => { + it.instance("tracks started jobs through completion", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const latch = yield* Deferred.make() + const job = yield* jobs.start({ + type: "test", + title: "test job", + run: Deferred.await(latch).pipe(Effect.as("done")), + }) + + expect(job.id.startsWith("job_")).toBe(true) + expect(job.status).toBe("running") + expect(job.title).toBe("test job") + + yield* Deferred.succeed(latch, undefined) + const done = yield* jobs.wait({ id: job.id }) + + expect(done.timedOut).toBe(false) + expect(done.info?.status).toBe("completed") + expect(done.info?.output).toBe("done") + expect((yield* jobs.list()).map((item) => item.id)).toEqual([job.id]) + }), + ) + + it.instance("returns a running snapshot when wait times out", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const job = yield* jobs.start({ type: "test", run: Effect.never }) + + const result = yield* jobs.wait({ id: job.id, timeout: 1 }) + + expect(result.timedOut).toBe(true) + expect(result.info?.status).toBe("running") + }), + ) + + it.instance("deduplicates concurrent starts for a running id", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const started = yield* Deferred.make() + const id = "job_test" + const [first, second] = yield* Effect.all( + [ + jobs.start({ + id, + type: "test", + run: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + }), + jobs.start({ id, type: "test", run: Effect.fail(new Error("duplicate started")) }), + ], + { concurrency: "unbounded" }, + ) + + yield* Deferred.await(started) + + expect(first.id).toBe(id) + expect(second.id).toBe(id) + expect(first.status).toBe("running") + expect(second.status).toBe("running") + expect((yield* jobs.list()).map((item) => item.id)).toEqual([id]) + + yield* jobs.cancel(id) + }), + ) + + it.instance("records failed jobs", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const job = yield* jobs.start({ type: "test", run: Effect.fail(new Error("boom")) }) + + const result = yield* jobs.wait({ id: job.id }) + + expect(result.info?.status).toBe("error") + expect(result.info?.error).toBe("boom") + }), + ) + + it.instance("can cancel running jobs", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const interrupted = yield* Deferred.make() + const job = yield* jobs.start({ + type: "test", + run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))), + }) + + const cancelled = yield* jobs.cancel(job.id) + + expect(cancelled?.status).toBe("cancelled") + yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second")) + expect((yield* jobs.get(job.id))?.status).toBe("cancelled") + }), + ) + + it.instance("releases blocking waits when backgrounded", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const latch = yield* Deferred.make() + const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) }) + const waiting = yield* jobs + .block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") }) + .pipe(Effect.forkChild) + + expect(yield* jobs.background(job.id)).toMatchObject({ id: job.id, status: "running" }) + expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } }) + + yield* Deferred.succeed(latch, undefined) + expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done") + }), + ) + + it.instance("returns immutable snapshots", () => + Effect.gen(function* () { + const jobs = yield* Job.Service + const job = yield* jobs.start({ type: "test", metadata: { value: "initial" }, run: Effect.succeed("done") }) + + if (job.metadata) job.metadata.value = "changed" + + expect((yield* jobs.get(job.id))?.metadata?.value).toBe("initial") + }), + ) +}) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 213e3cdce3..7a345da433 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -12,7 +12,7 @@ import { testEffect } from "../lib/effect" import { EventV2Bridge } from "@/event-v2-bridge" import { Storage } from "@/storage/storage" import { RuntimeFlags } from "@/effect/runtime-flags" -import { BackgroundJob } from "@/background/job" +import { Job } from "@/job" const layer = (experimentalWorkspaces: boolean) => Layer.mergeAll( @@ -24,7 +24,7 @@ const layer = (experimentalWorkspaces: boolean) => Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(SessionProjector.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), - Layer.provide(BackgroundJob.defaultLayer), + Layer.provide(Job.defaultLayer), ), ) const it = testEffect(layer(false)) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index dbaa724f76..d726deea85 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -11,7 +11,7 @@ import path from "path" import { fileURLToPath } from "url" import { NamedError } from "@opencode-ai/core/util/error" import { Agent as AgentSvc } from "../../src/agent/agent" -import { BackgroundJob } from "@/background/job" +import { Job } from "@/job" import { Command } from "../../src/command" import { Config } from "@/config/config" import { LSP } from "@/lsp/lsp" @@ -183,7 +183,7 @@ function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; proces lsp, makeMcp(input?.mcpInstructions), FSUtil.defaultLayer, - BackgroundJob.defaultLayer, + Job.defaultLayer, status, Database.defaultLayer, EventV2Bridge.defaultLayer, diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index c82f713d2b..375f9078d2 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -12,7 +12,7 @@ import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixtur import { testEffect } from "../lib/effect" import { Storage } from "@/storage/storage" import { RuntimeFlags } from "@/effect/runtime-flags" -import { BackgroundJob } from "@/background/job" +import { Job } from "@/job" import { EventV2Bridge } from "@/event-v2-bridge" import { GlobalBus } from "@/bus/global" @@ -24,7 +24,7 @@ const it = testEffect( Layer.provideMerge(EventV2Bridge.defaultLayer), Layer.provide(SessionProjector.defaultLayer), Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), - Layer.provide(BackgroundJob.defaultLayer), + Layer.provide(Job.defaultLayer), ), CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer, diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 97bb7db065..8f0d95d74e 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -3,7 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" -import { BackgroundJob } from "@/background/job" +import { Job } from "@/job" import { EventV2Bridge } from "@/event-v2-bridge" import { Config } from "@/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -19,7 +19,7 @@ import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { RuntimeFlags } from "@/effect/runtime-flags" import { disposeAllInstances } from "../fixture/fixture" -import { testEffect } from "../lib/effect" +import { pollWithTimeout, testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -35,7 +35,7 @@ const ref = { const layer = (flags: Partial = {}) => Layer.mergeAll( Agent.defaultLayer, - BackgroundJob.defaultLayer, + Job.defaultLayer, EventV2Bridge.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, @@ -480,9 +480,9 @@ describe("tool.task", () => { }), ) - it.instance("promotes a running foreground task without restarting it", () => + it.instance("backgrounds a running foreground task without restarting it", () => Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool const def = yield* tool.init() @@ -531,7 +531,12 @@ describe("tool.task", () => { expect(job).toBeDefined() if (!job) throw new Error("task job not found") expect(job.metadata?.parentSessionId).toBe(chat.id) - yield* jobs.promote(job.id) + yield* pollWithTimeout( + jobs + .backgroundAll({ sessionID: chat.id, type: "task" }) + .pipe(Effect.map((backgrounded) => (backgrounded.length > 0 ? backgrounded : undefined))), + "task never blocked the parent session", + ) const result = yield* Fiber.join(fiber) expect(result.metadata.background).toBe(true) @@ -548,7 +553,7 @@ describe("tool.task", () => { background.instance("execute launches background tasks without waiting for completion", () => Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool const def = yield* tool.init() @@ -584,15 +589,13 @@ describe("tool.task", () => { }), ) - background.instance("background task completion waits for running updates", () => + background.instance("running task_id reports the existing background task", () => Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool const def = yield* tool.init() const first = defer() - const second = defer() - const updated = defer() const injected = defer() let prompts = 0 const promptOps: TaskPromptOps = { @@ -603,9 +606,7 @@ describe("tool.task", () => { return Effect.succeed(reply(input, "done")) } prompts++ - if (prompts === 1) return Effect.promise(() => first.promise).pipe(Effect.as(reply(input, "first done"))) - updated.resolve(input) - return Effect.promise(() => second.promise).pipe(Effect.as(reply(input, "second done"))) + return Effect.promise(() => first.promise).pipe(Effect.as(reply(input, "first done"))) }, } const context = { @@ -640,27 +641,22 @@ describe("tool.task", () => { expect(result.metadata.sessionId).toBe(started.metadata.sessionId) expect(result.metadata.background).toBe(true) - expect(result.output).toContain("Background task updated") + expect(result.output).toContain("Background task already running") + expect(prompts).toBe(1) first.resolve() - expect((yield* jobs.get(started.metadata.sessionId))?.status).toBe("running") - expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([ - { type: "text", text: "also inspect cancellation" }, - ]) - - second.resolve() const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 }) expect(waited.info?.status).toBe("completed") - expect(waited.info?.output).toBe("second done") + expect(waited.info?.output).toBe("first done") const notification = yield* Effect.promise(() => injected.promise) expect(notification.variant).toBe("xhigh") expect(notification.parts[0]?.type).toBe("text") - if (notification.parts[0]?.type === "text") expect(notification.parts[0].text).toContain("second done") + if (notification.parts[0]?.type === "text") expect(notification.parts[0].text).toContain("first done") }), ) - background.instance("background tasks complete through the background job service", () => + background.instance("background tasks complete through the job service", () => Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool const def = yield* tool.init() @@ -693,7 +689,7 @@ describe("tool.task", () => { background.instance("background task completion does not wait for the parent async prompt", () => Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool const def = yield* tool.init() @@ -731,7 +727,7 @@ describe("tool.task", () => { background.instance("removing the parent session cancels running background tasks", () => Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const sessions = yield* Session.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool @@ -770,7 +766,7 @@ describe("tool.task", () => { background.instance("removing the child task session cancels its running background task", () => Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const sessions = yield* Session.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool @@ -809,7 +805,7 @@ describe("tool.task", () => { background.instance("cancelling the parent run cancels running background tasks", () => Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const runState = yield* SessionRunState.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool @@ -848,7 +844,7 @@ describe("tool.task", () => { it.instance("cancelling a child run cancels its own pre-runner task job", () => Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const runState = yield* SessionRunState.Service const sessions = yield* Session.Service const { chat } = yield* seed() @@ -869,7 +865,7 @@ describe("tool.task", () => { it.instance("cancelling a parent run recursively cancels descendant background tasks", () => Effect.gen(function* () { - const jobs = yield* BackgroundJob.Service + const jobs = yield* Job.Service const runState = yield* SessionRunState.Service const sessions = yield* Session.Service const { chat } = yield* seed() diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 379c675d3d..e9ea22e522 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -690,7 +690,7 @@ Affected schema: Change: - Remove the optional `background` bash parameter and process-local background settlement shape from the shipped tool. -- Retain the internal `BackgroundJob` prototype for a later integration slice. +- Retain the internal `Job` prototype for a later integration slice. Reason: diff --git a/specs/v2/todo.md b/specs/v2/todo.md index b774c51c6b..ee7f9dff2a 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -47,7 +47,7 @@ Next reviewed slices: remaining one-turn native-adapter use with a narrow typed dispatcher - batch streamed deltas and add covering context indexes - expose replayable Session event cursors over HTTP and the generated SDK where remote consumers need them -- integrate the new BackgroundJob service with V2 tool execution: support background +- integrate the new Job service with V2 tool execution: support background bash jobs and background agent dispatch with durable status observation, completion delivery, and explicit cancellation / continuation semantics - add durable/clustered interruption, retries, and stale-owner fencing only as From 4ce830a91947e3bc6f61edfdc62982a8dea33d09 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:53:56 -0500 Subject: [PATCH 17/27] fix(core): align v2 prompt tool names (#34557) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- .../src/session/runner/prompt/anthropic.txt | 36 +++++++++---------- .../core/src/session/runner/prompt/codex.txt | 6 ++-- .../src/session/runner/prompt/default.txt | 12 +++---- .../core/src/session/runner/prompt/gemini.txt | 16 ++++----- .../core/src/session/runner/prompt/gpt.txt | 4 +-- .../core/src/session/runner/prompt/kimi.txt | 4 +-- .../src/session/runner/prompt/trinity.txt | 8 ++--- 7 files changed, 43 insertions(+), 43 deletions(-) diff --git a/packages/core/src/session/runner/prompt/anthropic.txt b/packages/core/src/session/runner/prompt/anthropic.txt index 21d9c0e9f2..0fdf700d5f 100644 --- a/packages/core/src/session/runner/prompt/anthropic.txt +++ b/packages/core/src/session/runner/prompt/anthropic.txt @@ -9,20 +9,20 @@ If the user asks for help or wants to give feedback inform them of the following - To give feedback, users should report the issue at https://github.com/anomalyco/opencode -When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs +When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the webfetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs # Tone and style - Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. - Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. -- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. +- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session. - NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files. # Professional objectivity Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. # Task Management -You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. -These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. +You have access to the todowrite tool to help you manage and plan tasks. Use it VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. +This tool is also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed. @@ -30,13 +30,13 @@ Examples: user: Run the build and fix any type errors -assistant: I'm going to use the TodoWrite tool to write the following items to the todo list: +assistant: I'm going to use the todowrite tool to write the following items to the todo list: - Run the build - Fix any type errors -I'm now going to run the build using Bash. +I'm now going to run the build using the shell tool. -Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list. +Looks like I found 10 type errors. I'm going to use the todowrite tool to write 10 items to the todo list. marking the first todo as in_progress @@ -50,7 +50,7 @@ In the above example, the assistant completes all the tasks, including the 10 er user: Help me write a new feature that allows users to track their usage metrics and export them to various formats -assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task. +assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the todowrite tool to plan this task. Adding the following todos to the todo list: 1. Research existing metrics tracking in the codebase 2. Design the metrics collection system @@ -70,30 +70,30 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre # Doing tasks The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: - -- Use the TodoWrite tool to plan the task if required +- Use the todowrite tool to plan the task if required - Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. # Tool usage policy -- When doing file search, prefer to use the Task tool in order to reduce context usage. -- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description. +- When doing file search, prefer to use the subagent tool in order to reduce context usage. +- You should proactively use the subagent tool with specialized agents when the task at hand matches the agent's description. -- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. +- When webfetch returns a message about a redirect to a different host, you should immediately make a new webfetch request with the redirect URL provided in the response. - You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. -- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. -- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. -- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly. +- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple subagent tool calls. +- Use specialized tools instead of shell commands when possible, as this provides a better user experience. For file operations, use dedicated tools: read for reading files instead of cat/head/tail, edit for editing instead of sed/awk, and write for creating files instead of cat with heredoc or echo redirection. Reserve the shell tool exclusively for actual system commands and terminal operations that require shell execution. NEVER use shell echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. +- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the subagent tool instead of running search commands directly. user: Where are errors from the client handled? -assistant: [Uses the Task tool to find the files that handle client errors instead of using Glob or Grep directly] +assistant: [Uses the subagent tool to find the files that handle client errors instead of using glob or grep directly] user: What is the codebase structure? -assistant: [Uses the Task tool] +assistant: [Uses the subagent tool] -IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation. +IMPORTANT: Always use the todowrite tool to plan and track tasks throughout the conversation. # Code References diff --git a/packages/core/src/session/runner/prompt/codex.txt b/packages/core/src/session/runner/prompt/codex.txt index d595cadb0e..949390068d 100644 --- a/packages/core/src/session/runner/prompt/codex.txt +++ b/packages/core/src/session/runner/prompt/codex.txt @@ -9,9 +9,9 @@ You are an interactive CLI tool that helps users with software engineering tasks ## Tool usage - Prefer specialized tools over shell for file operations: - - Use Read to view files, Edit to modify files, and Write only when needed. - - Use Glob to find files by name and Grep to search file contents. -- Use Bash for terminal operations (git, bun, builds, tests, running scripts). + - Use read to view files, edit to modify files, and write only when needed. + - Use glob to find files by name and grep to search file contents. +- Use the shell tool for terminal operations (git, bun, builds, tests, running scripts). - Run tool calls in parallel when neither call needs the other’s output; otherwise run sequentially. ## Git and workspace hygiene diff --git a/packages/core/src/session/runner/prompt/default.txt b/packages/core/src/session/runner/prompt/default.txt index c8d904665e..7c22b337b7 100644 --- a/packages/core/src/session/runner/prompt/default.txt +++ b/packages/core/src/session/runner/prompt/default.txt @@ -6,12 +6,12 @@ If the user asks for help or wants to give feedback inform them of the following - /help: Get help with using opencode - To give feedback, users should report the issue at https://github.com/anomalyco/opencode/issues -When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from opencode docs at https://opencode.ai +When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the webfetch tool to gather information to answer the question from opencode docs at https://opencode.ai # Tone and style -You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). +You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. -Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. +Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do. @@ -72,14 +72,14 @@ The user will primarily request you perform software engineering tasks. This inc - Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially. - Implement the solution using all tools available to you - Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach. -- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time. +- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with the shell tool if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time. NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. # Tool usage policy -- When doing file search, prefer to use the Task tool in order to reduce context usage. -- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel. +- When doing file search, prefer to use the subagent tool in order to reduce context usage. +- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple shell tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel. You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail. diff --git a/packages/core/src/session/runner/prompt/gemini.txt b/packages/core/src/session/runner/prompt/gemini.txt index 87fe422bc7..0697207b19 100644 --- a/packages/core/src/session/runner/prompt/gemini.txt +++ b/packages/core/src/session/runner/prompt/gemini.txt @@ -19,18 +19,18 @@ You are opencode, an interactive CLI agent specializing in software engineering When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence: 1. **Understand:** Think about the user's request and the relevant codebase context. Use 'grep' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use 'read' to understand context and validate any assumptions you may have. 2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should try to use a self-verification loop by writing unit tests if relevant to the task. Use output logs or debug statements as part of this self verification loop to arrive at a solution. -3. **Implement:** Use the available tools (e.g., 'edit', 'write' 'bash' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). +3. **Implement:** Use the available tools (e.g., 'edit', 'write' 'shell' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates'). 4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands. 5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to. ## New Applications -**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write', 'edit' and 'bash'. +**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write', 'edit', and 'shell'. 1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. 3. **User Approval:** Obtain user approval for the proposed plan. -4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'bash' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible. +4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using the 'shell' tool for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible. 5. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors. 6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype. @@ -46,13 +46,13 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, - **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate. ## Security and Safety Rules -- **Explain Critical Commands:** Before executing commands with 'bash' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). +- **Explain Critical Commands:** Before executing commands with the 'shell' tool that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this). - **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information. ## Tool Usage - **File Paths:** Always use absolute paths when referring to files with tools like 'read' or 'write'. Relative paths are not supported. You must provide an absolute path. - **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase). -- **Command Execution:** Use the 'bash' tool for running shell commands, remembering the safety rule to explain modifying commands first. +- **Command Execution:** Use the 'shell' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user. - **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. @@ -79,7 +79,7 @@ model: [tool_call: ls for path '/path/to/project'] user: start the server implemented in server.js -model: [tool_call: bash for 'node server.js &' because it must run in the background] +model: [tool_call: shell for 'node server.js &' because it must run in the background] @@ -106,7 +106,7 @@ user: Yes model: [tool_call: write or edit to apply the refactoring to 'src/auth.py'] Refactoring complete. Running verification... -[tool_call: bash for 'ruff check src/auth.py && pytest'] +[tool_call: shell for 'ruff check src/auth.py && pytest'] (After verification passes) All checks passed. This is a stable checkpoint. @@ -125,7 +125,7 @@ Now I'll look for existing or related test files to understand current testing c (After reviewing existing tests and the file content) [tool_call: write to create /path/to/someFile.test.ts with the test code] I've written the tests. Now I'll run the project's test command to verify them. -[tool_call: bash for 'npm run test'] +[tool_call: shell for 'npm run test'] diff --git a/packages/core/src/session/runner/prompt/gpt.txt b/packages/core/src/session/runner/prompt/gpt.txt index 9068df4778..8870e5421e 100644 --- a/packages/core/src/session/runner/prompt/gpt.txt +++ b/packages/core/src/session/runner/prompt/gpt.txt @@ -2,8 +2,8 @@ You are OpenCode, You and the user share the same workspace and collaborate to a You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer. -- When searching for text or files, prefer using Glob and Grep tools (they are powered by `rg`) -- Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly. +- When searching for text or files, prefer using glob and grep tools (they are powered by `rg`) +- Parallelize tool calls whenever possible - especially file reads. When independent tool calls have no dependencies, issue them together in the same assistant message. Never chain together shell commands with separators like `echo "====";` as this renders to the user poorly. ## Editing Approach diff --git a/packages/core/src/session/runner/prompt/kimi.txt b/packages/core/src/session/runner/prompt/kimi.txt index beff6755f9..19461bcfe7 100644 --- a/packages/core/src/session/runner/prompt/kimi.txt +++ b/packages/core/src/session/runner/prompt/kimi.txt @@ -30,8 +30,8 @@ When building something from scratch, you should: Always use tools to implement your code changes: - Use `write`/`edit` to create or modify source files. Code that only appears in your text response is NOT saved to the file system and will not take effect. -- Use `bash` to run and test your code after writing it. -- Iterate: if tests fail, read the error, fix the code with `write`/`edit`, and re-test with `bash`. +- Use `shell` to run and test your code after writing it. +- Iterate: if tests fail, read the error, fix the code with `write`/`edit`, and re-test with `shell`. When working on an existing codebase, you should: diff --git a/packages/core/src/session/runner/prompt/trinity.txt b/packages/core/src/session/runner/prompt/trinity.txt index 28ee4c4f26..d9435133cf 100644 --- a/packages/core/src/session/runner/prompt/trinity.txt +++ b/packages/core/src/session/runner/prompt/trinity.txt @@ -1,9 +1,9 @@ You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. # Tone and style -You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). +You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. -Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. +Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do. @@ -74,13 +74,13 @@ The user will primarily request you perform software engineering tasks. This inc - Use the available search tools to understand the codebase and the user's query. Use one tool per message; after each result, decide the next step and call one tool again. - Implement the solution using all tools available to you - Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach. -- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time. +- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with the shell tool if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time. NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. # Tool usage policy -- When doing file search, prefer to use the Task tool in order to reduce context usage. +- When doing file search, prefer to use the subagent tool in order to reduce context usage. - Use exactly one tool per assistant message. After each tool call, wait for the result before continuing. - When the user's request is vague, use the question tool to clarify before reading files or making changes. - Avoid repeating the same tool with the same parameters once you have useful results. Use the result to take the next step (e.g. pick one match, read that file, then act); do not search again in a loop. From 02cb35088012cc2b994dd8df8cf6d8518f5ff5e0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 29 Jun 2026 18:52:00 -0400 Subject: [PATCH 18/27] feat(tui): refresh agents after update events --- packages/core/src/agent.ts | 7 +- packages/core/src/config/plugin/agent.ts | 1 + packages/core/src/config/plugin/external.ts | 4 +- packages/core/src/plugin/internal.ts | 2 +- packages/core/test/agent.test.ts | 28 +- packages/core/test/config/agent.test.ts | 5 +- .../core/test/session-runner-recorded.test.ts | 2 +- packages/core/test/session-runner.test.ts | 2 +- packages/opencode/test/event-manifest.test.ts | 4 +- packages/schema/src/agent.ts | 19 +- packages/schema/src/event-manifest.ts | 2 + packages/schema/test/event-manifest.test.ts | 23 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 156 ++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 280 +++++++++++++++++- packages/tui/src/context/data.tsx | 3 + packages/tui/src/routes/session/index.tsx | 21 +- packages/tui/test/cli/tui/data.test.tsx | 47 +++ 17 files changed, 576 insertions(+), 30 deletions(-) diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index ebdecb5c4c..3d5cc3b0c0 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -3,6 +3,7 @@ export * as AgentV2 from "./agent" import { makeLocationNode } from "./effect/app-node" import { Array, Context, Effect, Layer, Types } from "effect" import { Agent } from "@opencode-ai/schema/agent" +import { EventV2 } from "./event" import { State } from "./state" export const ID = Agent.ID @@ -14,6 +15,8 @@ export const Color = Agent.Color export const Info = Agent.Info export type Info = Agent.Info +export const Event = Agent.Event + export interface Selection { readonly id: ID readonly info: Info | undefined @@ -45,6 +48,7 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { + const events = yield* EventV2.Service const state = State.create({ initial: () => ({ agents: new Map() }), draft: (draft) => ({ @@ -63,6 +67,7 @@ export const layer = Layer.effect( draft.agents.delete(id) }, }), + finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), }) const selectable = (agent: Info | undefined) => agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined @@ -108,4 +113,4 @@ export const layer = Layer.effect( export const locationLayer = layer -export const node = makeLocationNode({ service: Service, layer, deps: [] }) +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] }) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 48efe75804..1dbff023f4 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -60,6 +60,7 @@ export const Plugin = define({ const configuredDefault = Config.latest(documents, "default_agent") if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) for (const current of draft.list()) { + yield* Effect.log({ msg: "applying permissions", id: current.id, permissions: global }) draft.update(current.id, (agent) => agent.permissions.push(...global)) } diff --git a/packages/core/src/config/plugin/external.ts b/packages/core/src/config/plugin/external.ts index d8ace63b3d..b5163329c9 100644 --- a/packages/core/src/config/plugin/external.ts +++ b/packages/core/src/config/plugin/external.ts @@ -76,7 +76,7 @@ export const Plugin = define({ ? pathToFileURL(ref.package).href : (yield* npm.add(ref.package)).entrypoint if (!entrypoint) return - + yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint }) const mod = yield* Effect.promise(() => import(entrypoint)) const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default const plugin = "effect" in value ? value : PluginPromise.fromPromise(value) @@ -86,6 +86,6 @@ export const Plugin = define({ }) }).pipe(Effect.ignoreCause) } - }).pipe(Effect.forkScoped({ startImmediately: true })) + }) }), }) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index d1262372a9..3a4d0092c4 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -114,11 +114,11 @@ const layer = Layer.effectDiscard( yield* add(CommandPlugin.Plugin) yield* add(SkillPlugin.Plugin) yield* add(ModelsDevPlugin) + yield* add(ConfigExternalPlugin.Plugin) yield* add(ConfigAgentPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigSkillPlugin.Plugin) for (const item of ProviderPlugins) yield* add(item) - yield* add(ConfigExternalPlugin.Plugin) yield* add(ConfigProviderPlugin.Plugin) yield* add(VariantPlugin.Plugin) // Embedder-contributed plugins are added last so they layer over config. diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index 0e0b05803f..4bba451895 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" -import { Effect, Exit, Scope } from "effect" +import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" +import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { AgentPlugin } from "@opencode-ai/core/plugin/agent" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -8,9 +9,32 @@ import { location } from "./fixture/location" import { testEffect } from "./lib/effect" import { agentHost, host } from "./plugin/host" -const it = testEffect(AgentV2.locationLayer) +const testLocation = location({ directory: AbsolutePath.make("/project") }) +const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation)) + +const it = testEffect( + AgentV2.locationLayer.pipe( + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(locationLayer), + ), +) describe("AgentV2", () => { + it.effect("publishes an updated event after agent changes", () => + Effect.gen(function* () { + const agent = yield* AgentV2.Service + const events = yield* EventV2.Service + const updated = yield* events + .subscribe(AgentV2.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow + + yield* agent.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), () => {})) + + expect(yield* Fiber.join(updated)).toMatchObject([{ location: { directory: testLocation.directory } }]) + }), + ) + it.effect("starts without agents", () => Effect.gen(function* () { const agent = yield* AgentV2.Service diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index ea553671bd..7085f65cd7 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -5,6 +5,7 @@ import { Effect, Layer, Schema } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent" +import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -12,7 +13,9 @@ import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" import { agentHost, host } from "../plugin/host" -const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer)) +const it = testEffect( + Layer.mergeAll(AgentV2.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer)), FSUtil.defaultLayer), +) const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigAgentPlugin.Plugin", () => { diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index ac87c0e366..fd819f9f7a 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -59,7 +59,7 @@ const permission = Layer.succeed( }), ) const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const agents = AgentV2.layer +const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.com/v1" }, diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 94c631ac86..aa9dfd63f6 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -129,7 +129,7 @@ const registry = ToolRegistry.layer.pipe( Layer.provide(applications), Layer.provide(ToolOutputStore.defaultLayer), ) -const agents = AgentV2.layer +const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) const echo = Layer.effectDiscard( ToolRegistry.Service.use((registry) => registry.register({ diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts index da0f80cee5..a38d96cb83 100644 --- a/packages/opencode/test/event-manifest.test.ts +++ b/packages/opencode/test/event-manifest.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { SessionEvent } from "@opencode-ai/core/session/event" +import { Agent } from "@opencode-ai/schema" import { EventManifest as SchemaEventManifest } from "@opencode-ai/schema/event-manifest" import { Todo } from "@/session/todo" import { EventManifest } from "@/event-manifest" @@ -9,8 +10,9 @@ describe("public event manifest", () => { expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) - expect(EventManifest.Latest.size).toBe(88) + expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93) expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) + expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(EventManifest.Latest.has("server.connected")).toBe(true) diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index adf7aec282..9313ece4ef 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -1,12 +1,15 @@ export * as Agent from "./agent" import { Schema } from "effect" +import { define, inventory } from "./event" import { optional } from "./schema" import { Model } from "./model" import { Permission } from "./permission" import { Provider } from "./provider" import { PositiveInt, statics } from "./schema" +const Updated = define({ type: "agent.updated", schema: {} }) + export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID")) export type ID = typeof ID.Type @@ -33,6 +36,20 @@ export const Info = Schema.Struct({ .pipe( statics((schema) => ({ empty: (id: ID) => - schema.make({ id, request: { headers: {}, body: {} }, mode: "all", hidden: false, permissions: [] }), + schema.make({ + id, + request: { headers: {}, body: {} }, + mode: "all", + hidden: false, + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + { action: "external_directory", resource: "*", effect: "ask" }, + ], + }), })), ) + +export const Event = { + Updated, + Definitions: inventory(Updated), +} diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index e47bc6ed90..cbd9d86819 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -1,5 +1,6 @@ export * as EventManifest from "./event-manifest" +import { Agent } from "./agent" import { Catalog } from "./catalog" import { Durable } from "./durable-event-manifest" import { Event } from "./event" @@ -41,6 +42,7 @@ const foundationDefinitions = Event.inventory( ...ModelsDev.Event.Definitions, ...Integration.Event.Definitions, ...Catalog.Event.Definitions, + ...Agent.Event.Definitions, ...coreDefinitions, ) diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index 5694afdd30..230eb93be4 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src" +import { Agent, FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src" import { EventManifest } from "../src/event-manifest" import { IdeEvent } from "../src/ide-event" import { SessionEvent } from "../src/session-event" @@ -9,8 +9,14 @@ import { WorkspaceEvent } from "../src/workspace-event" describe("public event manifest", () => { test("owns the complete public event surface", () => { - expect(EventManifest.ServerDefinitions.length).toBe(55) - expect(EventManifest.Definitions.length).toBe(85) + expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(63) + expect(EventManifest.ServerDefinitions.filter((definition) => definition.type === "agent.updated")).toEqual([ + Agent.Event.Updated, + ]) + expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(93) + expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([ + Agent.Event.Updated, + ]) expect(SessionV1.Event.Definitions).toEqual([ SessionV1.Event.Created, SessionV1.Event.Updated, @@ -23,8 +29,10 @@ describe("public event manifest", () => { SessionV1.Event.Diff, SessionV1.Event.Error, ]) - expect(EventManifest.Latest.size).toBe(85) - expect(EventManifest.Durable.size).toBe(32) + expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93) + expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) + expect(Agent.Event.Updated.durable).toBeUndefined() + expect(EventManifest.Durable.has("agent.updated")).toBe(false) }) test("uses canonical definitions for current public events", () => { @@ -34,7 +42,9 @@ describe("public event manifest", () => { expect(Workspace.Event.Definitions).toBe(WorkspaceEvent.Definitions) expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated) + expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated) expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated) + expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated]) expect(Project.Event.Definitions).toEqual([Project.Event.Updated]) expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited]) expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated]) @@ -42,7 +52,8 @@ describe("public event manifest", () => { expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated]) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed]) - expect(EventManifest.Definitions.slice(40, 43)).toEqual([ + const sessionV1TailStart = EventManifest.Definitions.indexOf(SessionV1.Event.PartDelta) + expect(EventManifest.Definitions.slice(sessionV1TailStart, sessionV1TailStart + 3)).toEqual([ SessionV1.Event.PartDelta, SessionV1.Event.Diff, SessionV1.Event.Error, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 80aec1244c..0a261778bf 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -279,6 +279,8 @@ import type { V2FsListResponses, V2FsReadErrors, V2FsReadResponses, + V2GenerateTextErrors, + V2GenerateTextResponses, V2HealthGetErrors, V2HealthGetResponses, V2IntegrationAttemptCancelErrors, @@ -311,6 +313,10 @@ import type { V2ProjectCopyRefreshResponses, V2ProjectCopyRemoveErrors, V2ProjectCopyRemoveResponses, + V2ProjectCurrentErrors, + V2ProjectCurrentResponses, + V2ProjectDirectoriesErrors, + V2ProjectDirectoriesResponses, V2ProviderGetErrors, V2ProviderGetResponses, V2ProviderListErrors, @@ -343,6 +349,8 @@ import type { V2SessionCreateResponses, V2SessionEventsErrors, V2SessionEventsResponses, + V2SessionForkErrors, + V2SessionForkResponses, V2SessionGetErrors, V2SessionGetResponses, V2SessionHistoryErrors, @@ -5548,6 +5556,41 @@ export class Session3 extends HeyApiClient { }) } + /** + * Fork session + * + * Create a child session by copying projected history from the parent. When messageID is supplied, copy messages before that boundary. + */ + public fork( + parameters: { + sessionID: string + messageID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "messageID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/fork", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Switch session agent * @@ -5944,6 +5987,48 @@ export class Model extends HeyApiClient { } } +export class Generate extends HeyApiClient { + /** + * Generate text + * + * Run one stateless model generation at the requested location and return the assistant text. Uses the location's default model when none is specified. + */ + public text( + parameters?: { + location?: { + directory?: string + workspace?: string + } + prompt?: string + model?: ModelRef + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "prompt" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/generate", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + export class Provider2 extends HeyApiClient { /** * List providers @@ -6363,6 +6448,67 @@ export class Credential extends HeyApiClient { } } +export class Project2 extends HeyApiClient { + /** + * Get current project + * + * Resolve the project for the requested location. + */ + public current( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/project/current", + ...options, + ...params, + }) + } + + /** + * List project directories + * + * List known local absolute directories for a project. + */ + public directories( + parameters: { + projectID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + V2ProjectDirectoriesResponses, + V2ProjectDirectoriesErrors, + ThrowOnError + >({ + url: "/api/project/{projectID}/directories", + ...options, + ...params, + }) + } +} + export class Request extends HeyApiClient { /** * List pending permission requests @@ -7233,6 +7379,11 @@ export class V2 extends HeyApiClient { return (this._model ??= new Model({ client: this.client })) } + private _generate?: Generate + get generate(): Generate { + return (this._generate ??= new Generate({ client: this.client })) + } + private _provider?: Provider2 get provider(): Provider2 { return (this._provider ??= new Provider2({ client: this.client })) @@ -7248,6 +7399,11 @@ export class V2 extends HeyApiClient { return (this._credential ??= new Credential({ client: this.client })) } + private _project?: Project2 + get project(): Project2 { + return (this._project ??= new Project2({ client: this.client })) + } + private _permission?: Permission3 get permission(): Permission3 { return (this._permission ??= new Permission3({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9259dad941..3902f04c99 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -9,6 +9,7 @@ export type Event = | EventIntegrationUpdated | EventIntegrationConnectionUpdated | EventCatalogUpdated + | EventAgentUpdated | EventSessionCreated | EventSessionUpdated | EventSessionDeleted @@ -20,6 +21,7 @@ export type Event = | EventSessionNextModelSwitched | EventSessionNextMoved | EventSessionNextRenamed + | EventSessionNextForked | EventSessionNextPrompted | EventSessionNextPromptAdmitted | EventSessionNextContextUpdated @@ -646,7 +648,6 @@ export type Prompt = { text: string files?: Array agents?: Array - system?: string } export type Pty = { @@ -783,6 +784,13 @@ export type GlobalEvent = { [key: string]: unknown } } + | { + id: string + type: "agent.updated" + properties: { + [key: string]: unknown + } + } | { id: string type: "session.created" @@ -880,6 +888,16 @@ export type GlobalEvent = { title: string } } + | { + id: string + type: "session.next.forked" + properties: { + timestamp: number + sessionID: string + parentID: string + messageID?: string + } + } | { id: string type: "session.next.prompted" @@ -1667,6 +1685,7 @@ export type GlobalEvent = { | SyncEventSessionNextModelSwitched | SyncEventSessionNextMoved | SyncEventSessionNextRenamed + | SyncEventSessionNextForked | SyncEventSessionNextPrompted | SyncEventSessionNextPromptAdmitted | SyncEventSessionNextContextUpdated @@ -2756,11 +2775,17 @@ export type SessionNotFoundError = { message: string } +export type MessageNotFoundError = { + _tag: "MessageNotFoundError" + sessionID: string + messageID: string + message: string +} + export type PromptInput = { text: string files?: Array agents?: Array - system?: string } export type ConflictError = { @@ -2781,18 +2806,12 @@ export type UnknownError1 = { ref?: string } -export type MessageNotFoundError = { - _tag: "MessageNotFoundError" - sessionID: string - messageID: string - message: string -} - export type SessionDurableEvent = | SessionNextAgentSwitched | SessionNextModelSwitched | SessionNextMoved | SessionNextRenamed + | SessionNextForked | SessionNextPrompted | SessionNextPromptAdmitted | SessionNextContextUpdated @@ -2834,6 +2853,12 @@ export type SessionMessagesResponse = { } } +export type GenerateTextResponse = { + data: { + text: string + } +} + export type ProviderNotFoundError = { _tag: "ProviderNotFoundError" providerID: string @@ -2928,6 +2953,7 @@ export type V2Event = | IntegrationUpdated | IntegrationConnectionUpdated | CatalogUpdated + | AgentUpdated | SessionCreated | SessionUpdated | SessionDeleted @@ -2939,6 +2965,7 @@ export type V2Event = | SessionNextModelSwitched | SessionNextMoved | SessionNextRenamed + | SessionNextForked | SessionNextPrompted | SessionNextPromptAdmitted | SessionNextContextUpdated @@ -3464,6 +3491,23 @@ export type SyncEventSessionNextRenamed = { } } +export type SyncEventSessionNextForked = { + type: "sync" + id: string + syncEvent: { + type: "session.next.forked.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + parentID: string + messageID?: string + } + } +} + export type SyncEventSessionNextPrompted = { type: "sync" id: string @@ -3959,10 +4003,12 @@ export type ConfigV2ExperimentalPolicy = { resource: string } -export type ProjectDirectories = Array<{ +export type ProjectDirectory = { directory: string strategy?: string -}> +} + +export type ProjectDirectories = Array export type PtyTicketConnectToken = { ticket: string @@ -4096,7 +4142,6 @@ export type SessionMessageUser = { text: string files?: Array agents?: Array - system?: string type: "user" } @@ -4357,6 +4402,26 @@ export type SessionNextRenamed = { } } +export type SessionNextForked = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.forked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + parentID: string + messageID?: string + } +} + export type SessionNextPrompted = { id: string metadata?: { @@ -5115,6 +5180,11 @@ export type IntegrationAttemptStatus = } } +export type ProjectCurrent = { + id: string + directory: string +} + export type PermissionV2Request = { id: string sessionID: string @@ -5224,6 +5294,23 @@ export type CatalogUpdated = { } } +export type AgentUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "agent.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type SessionCreated = { id: string metadata?: { @@ -6371,6 +6458,14 @@ export type EventCatalogUpdated = { } } +export type EventAgentUpdated = { + id: string + type: "agent.updated" + properties: { + [key: string]: unknown + } +} + export type EventSessionCreated = { id: string type: "session.created" @@ -6479,6 +6574,17 @@ export type EventSessionNextRenamed = { } } +export type EventSessionNextForked = { + id: string + type: "session.next.forked" + properties: { + timestamp: number + sessionID: string + parentID: string + messageID?: string + } +} + export type EventSessionNextPrompted = { id: string type: "session.next.prompted" @@ -11700,6 +11806,45 @@ export type V2SessionGetResponses = { export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses] +export type V2SessionForkData = { + body: { + messageID?: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/fork" +} + +export type V2SessionForkErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | MessageNotFoundError + */ + 404: MessageNotFoundError | SessionNotFoundError +} + +export type V2SessionForkError = V2SessionForkErrors[keyof V2SessionForkErrors] + +export type V2SessionForkResponses = { + /** + * Success + */ + 200: { + data: SessionV2Info + } +} + +export type V2SessionForkResponse = V2SessionForkResponses[keyof V2SessionForkResponses] + export type V2SessionSwitchAgentData = { body: { agent: string @@ -12353,6 +12498,47 @@ export type V2ModelListResponses = { export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses] +export type V2GenerateTextData = { + body: { + prompt: string + model?: ModelRef + } + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/generate" +} + +export type V2GenerateTextErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2GenerateTextError = V2GenerateTextErrors[keyof V2GenerateTextErrors] + +export type V2GenerateTextResponses = { + /** + * GenerateTextResponse + */ + 200: GenerateTextResponse +} + +export type V2GenerateTextResponse = V2GenerateTextResponses[keyof V2GenerateTextResponses] + export type V2ProviderListData = { body?: never path?: never @@ -12793,6 +12979,76 @@ export type V2CredentialUpdateResponses = { export type V2CredentialUpdateResponse = V2CredentialUpdateResponses[keyof V2CredentialUpdateResponses] +export type V2ProjectCurrentData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/project/current" +} + +export type V2ProjectCurrentErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ProjectCurrentError = V2ProjectCurrentErrors[keyof V2ProjectCurrentErrors] + +export type V2ProjectCurrentResponses = { + /** + * Project.Current + */ + 200: ProjectCurrent +} + +export type V2ProjectCurrentResponse = V2ProjectCurrentResponses[keyof V2ProjectCurrentResponses] + +export type V2ProjectDirectoriesData = { + body?: never + path: { + projectID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/project/{projectID}/directories" +} + +export type V2ProjectDirectoriesErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2ProjectDirectoriesError = V2ProjectDirectoriesErrors[keyof V2ProjectDirectoriesErrors] + +export type V2ProjectDirectoriesResponses = { + /** + * Project.Directories + */ + 200: ProjectDirectories +} + +export type V2ProjectDirectoriesResponse = V2ProjectDirectoriesResponses[keyof V2ProjectDirectoriesResponses] + export type V2PermissionRequestListData = { body?: never path?: never diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index ae56634a22..12ba3a7f3a 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -158,6 +158,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.provider.refresh(event.location), ]) break + case "agent.updated": + void result.location.agent.refresh(event.location) + break case "session.next.agent.switched": if (store.session.info[event.data.sessionID]) setStore("session", "info", event.data.sessionID, "agent", event.data.agent) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 95ec719d59..b7ff581709 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -170,9 +170,22 @@ export function Session() { }) onCleanup(() => setEpilogue()) const messages = sessionMessages + const descendantSessionIDs = createMemo(() => { + if (session()?.parentID) return [] + const sessions = data.session.list() + const childrenByParent = sessions.reduce((acc, item) => { + if (!item.parentID) return acc + acc.set(item.parentID, [...(acc.get(item.parentID) ?? []), item.id]) + return acc + }, new Map()) + function collect(sessionID: string): string[] { + return (childrenByParent.get(sessionID) ?? []).flatMap((id) => [id, ...collect(id)]) + } + return collect(route.sessionID) + }) const permissions = createMemo(() => { if (session()?.parentID) return [] - return data.session.permission.list(route.sessionID) ?? [] + return [route.sessionID, ...descendantSessionIDs()].flatMap((sessionID) => data.session.permission.list(sessionID) ?? []) }) const questions = createMemo(() => { if (session()?.parentID) return [] @@ -227,6 +240,12 @@ export function Session() { const editor = useEditorContext() const rows = createSessionRows(() => route.sessionID) + createEffect( + on(descendantSessionIDs, (sessionIDs) => { + void Promise.all(sessionIDs.map((sessionID) => data.session.permission.refresh(sessionID))) + }), + ) + createEffect(() => { const sessionID = route.sessionID void (async () => { diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 50f5306c21..e8750e79f5 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -356,6 +356,53 @@ test("refreshes effective catalog data after catalog updates", async () => { } }) +test("refreshes agents after agent updates", async () => { + const events = createEventStream() + let requests = 0 + const calls = createFetch((url) => { + if (url.pathname !== "/api/agent") return + requests++ + return json({ + location: { directory, project: { id: "proj_test", directory } }, + data: [ + { + id: requests === 1 ? "build" : "reviewer", + request: { headers: {}, body: {} }, + mode: "primary", + hidden: false, + permissions: [], + }, + ], + }) + }, events) + let data!: ReturnType + + function Probe() { + data = useData() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => data.location.agent.list()?.[0]?.id === "build") + emitEvent(events, { id: "evt_agent", type: "agent.updated", data: {} }) + await wait(() => data.location.agent.list()?.[0]?.id === "reviewer") + } finally { + app.renderer.destroy() + } +}) + test("refreshes references after updates", async () => { const events = createEventStream() let requests = 0 From 1b83c08b8afb30486fa4b84719b070f606423351 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 30 Jun 2026 00:17:54 -0400 Subject: [PATCH 19/27] Update service configuration CLI --- packages/cli/src/commands/commands.ts | 18 ++- packages/cli/src/commands/handlers/serve.ts | 17 ++- .../handlers/service/{password.ts => get.ts} | 8 +- .../cli/src/commands/handlers/service/set.ts | 11 ++ .../src/commands/handlers/service/unset.ts | 11 ++ packages/cli/src/index.ts | 4 +- packages/cli/src/services/daemon.ts | 121 +++++++++++++++--- 7 files changed, 157 insertions(+), 33 deletions(-) rename packages/cli/src/commands/handlers/service/{password.ts => get.ts} (54%) create mode 100644 packages/cli/src/commands/handlers/service/set.ts create mode 100644 packages/cli/src/commands/handlers/service/unset.ts diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 18db4006a8..e644c51be0 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -44,18 +44,26 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Spec.make("restart", { description: "Restart the background server" }), Spec.make("status", { description: "Show background server status" }), Spec.make("stop", { description: "Stop the background server" }), - Spec.make("password", { - description: "Get or set the server password", - params: { value: Argument.string("value").pipe(Argument.optional) }, + Spec.make("get", { + description: "Get service configuration", + params: { key: Argument.string("key").pipe(Argument.optional) }, + }), + Spec.make("set", { + description: "Set service configuration", + params: { key: Argument.string("key"), value: Argument.string("value") }, + }), + Spec.make("unset", { + description: "Unset service configuration", + params: { key: Argument.string("key") }, }), ], }), Spec.make("serve", { description: "Start the v2 API server", params: { - hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")), + hostname: Flag.string("hostname").pipe(Flag.optional), port: Flag.integer("port").pipe(Flag.optional), - register: Flag.boolean("register").pipe(Flag.withDefault(false)), + service: Flag.boolean("service").pipe(Flag.withDefault(false)), stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)), }, }), diff --git a/packages/cli/src/commands/handlers/serve.ts b/packages/cli/src/commands/handlers/serve.ts index 1357e7631c..3cbeded098 100644 --- a/packages/cli/src/commands/handlers/serve.ts +++ b/packages/cli/src/commands/handlers/serve.ts @@ -12,6 +12,7 @@ import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { Daemon } from "../../services/daemon" import { Updater } from "../../services/updater" +import { randomBytes } from "crypto" export default Runtime.handler( Commands.commands.serve, @@ -21,18 +22,28 @@ export default Runtime.handler( const daemon = yield* Daemon.Service const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD - const password = input.stdio ? standalonePassword : yield* daemon.password() + const config = input.service ? yield* daemon.config() : {} + const password = input.service + ? yield* daemon.password() + : standalonePassword || randomBytes(32).toString("base64url") if (!password) return yield* Effect.fail(new Error("Missing server password")) - const address = yield* listen(input.hostname, input.port, password) + const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1" + const port = Option.isSome(input.port) + ? input.port + : config.port === undefined + ? Option.none() + : Option.some(config.port) + const address = yield* listen(hostname, port, password) yield* Effect.tryPromise(() => createOpencodeClient({ baseUrl: HttpServer.formatAddress(address), headers: ServerAuth.headers({ password }), }).v2.location.get(undefined, { throwOnError: true }), ) - if (input.register) yield* daemon.register(address) + if (input.service) yield* daemon.register(address) const url = HttpServer.formatAddress(address) console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`) + if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`) const updater = yield* Updater.Service yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped) return yield* (input.stdio ? waitForStdinClose() : Effect.never) diff --git a/packages/cli/src/commands/handlers/service/password.ts b/packages/cli/src/commands/handlers/service/get.ts similarity index 54% rename from packages/cli/src/commands/handlers/service/password.ts rename to packages/cli/src/commands/handlers/service/get.ts index 6bf49d50d0..aaaebf14a1 100644 --- a/packages/cli/src/commands/handlers/service/password.ts +++ b/packages/cli/src/commands/handlers/service/get.ts @@ -6,11 +6,9 @@ import { Runtime } from "../../../framework/runtime" import { Daemon } from "../../../services/daemon" export default Runtime.handler( - Commands.commands.service.commands.password, - Effect.fn("cli.service.password")(function* (input) { + Commands.commands.service.commands.get, + Effect.fn("cli.service.get")(function* (input) { const daemon = yield* Daemon.Service - const value = Option.getOrUndefined(input.value) - if (value !== undefined) yield* daemon.stop() - process.stdout.write((yield* daemon.password(value)) + EOL) + process.stdout.write((yield* daemon.get(Option.getOrUndefined(input.key))) + EOL) }), ) diff --git a/packages/cli/src/commands/handlers/service/set.ts b/packages/cli/src/commands/handlers/service/set.ts new file mode 100644 index 0000000000..d1181ef14a --- /dev/null +++ b/packages/cli/src/commands/handlers/service/set.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.service.commands.set, + Effect.fn("cli.service.set")(function* (input) { + yield* (yield* Daemon.Service).set(input.key, input.value) + }), +) diff --git a/packages/cli/src/commands/handlers/service/unset.ts b/packages/cli/src/commands/handlers/service/unset.ts new file mode 100644 index 0000000000..f16bbe32cc --- /dev/null +++ b/packages/cli/src/commands/handlers/service/unset.ts @@ -0,0 +1,11 @@ +import * as Effect from "effect/Effect" +import { Commands } from "../../commands" +import { Runtime } from "../../../framework/runtime" +import { Daemon } from "../../../services/daemon" + +export default Runtime.handler( + Commands.commands.service.commands.unset, + Effect.fn("cli.service.unset")(function* (input) { + yield* (yield* Daemon.Service).unset(input.key) + }), +) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f8a95977df..e013152adb 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -30,7 +30,9 @@ const Handlers = Runtime.handlers(Commands, { restart: () => import("./commands/handlers/service/restart"), status: () => import("./commands/handlers/service/status"), stop: () => import("./commands/handlers/service/stop"), - password: () => import("./commands/handlers/service/password"), + get: () => import("./commands/handlers/service/get"), + set: () => import("./commands/handlers/service/set"), + unset: () => import("./commands/handlers/service/unset"), }, serve: () => import("./commands/handlers/serve"), }) diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index 964822d1d2..98c1540388 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -15,6 +15,10 @@ export interface Interface { readonly status: () => Effect.Effect readonly stop: () => Effect.Effect readonly password: (value?: string) => Effect.Effect + readonly config: () => Effect.Effect + readonly get: (key?: string) => Effect.Effect + readonly set: (key: string, value: string) => Effect.Effect + readonly unset: (key: string) => Effect.Effect readonly register: (address: HttpServer.Address) => Effect.Effect } @@ -28,9 +32,20 @@ const Registration = Schema.Struct({ }) type Registration = typeof Registration.Type -const Config = Schema.Struct({ +const ServiceConfig = Schema.Struct({ + hostname: Schema.optional(Schema.String), + port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), password: Schema.optional(Schema.String), }) +export type ServiceConfig = typeof ServiceConfig.Type + +const serviceConfigKeys = ["hostname", "port", "password"] as const +type ServiceConfigKey = (typeof serviceConfigKeys)[number] + +function serviceConfigKey(key: string): ServiceConfigKey { + if (serviceConfigKeys.includes(key as ServiceConfigKey)) return key as ServiceConfigKey + throw new Error(`Unknown service config key: ${key}`) +} function sameRegistration(left: Registration, right: Registration) { return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid @@ -41,32 +56,100 @@ export const layer = Layer.effect( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const directory = Global.Path.state - const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json") + const file = path.join(directory, InstallationChannel === "local" ? "service-local.json" : "service.json") const configFile = path.join(Global.Path.config, "service.json") - const legacyPasswordFile = path.join(directory, "password") const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) - const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(Config)) + const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig)) + + const config = Effect.fn("cli.daemon.config")(function* () { + return yield* fs.readFileString(configFile).pipe( + Effect.flatMap(decodeServiceConfig), + Effect.catch(() => Effect.succeed({} as ServiceConfig)), + ) + }) + + const writeConfig = Effect.fn("cli.daemon.writeConfig")(function* (value: ServiceConfig) { + const temp = configFile + ".tmp" + yield* fs.makeDirectory(path.dirname(configFile), { recursive: true }) + yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 }) + yield* fs.rename(temp, configFile) + }) const password = Effect.fn("cli.daemon.password")(function* (value?: string) { - const config = yield* fs - .readFileString(configFile) - .pipe(Effect.flatMap(decodeConfig), Effect.catch(() => Effect.succeed(undefined))) - if (value === undefined && config?.password) return config.password - - const legacy = yield* fs - .readFileString(legacyPasswordFile) - .pipe(Effect.catch(() => Effect.succeed(undefined))) - const next = value ?? legacy ?? randomBytes(32).toString("base64url") + const existing = yield* config() + if (value === undefined && existing.password) return existing.password + const next = value ?? randomBytes(32).toString("base64url") // Keep one private credential across server restarts so discovered clients // can reconnect without exposing a password flag or environment variable. - const temp = configFile + ".tmp" - yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 }) - yield* fs.rename(temp, configFile) - if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore) + yield* writeConfig({ ...existing, password: next }) return next }) + const get = Effect.fn("cli.daemon.get")(function* (key?: string) { + if (key === undefined) { + const { password: _password, ...safe } = yield* config() + return JSON.stringify(safe, null, 2) + } + switch (serviceConfigKey(key)) { + case "hostname": { + return (yield* config()).hostname ?? "" + } + case "port": { + const port = (yield* config()).port + return port === undefined ? "" : String(port) + } + case "password": { + return yield* password() + } + } + }) + + const set = Effect.fn("cli.daemon.set")(function* (key: string, value: string) { + switch (serviceConfigKey(key)) { + case "hostname": { + yield* stop() + yield* writeConfig({ ...(yield* config()), hostname: value }) + return + } + case "port": { + const port = Number(value) + if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535") + yield* stop() + yield* writeConfig({ ...(yield* config()), port }) + return + } + case "password": { + yield* stop() + yield* password(value) + return + } + } + }) + + const unset = Effect.fn("cli.daemon.unset")(function* (key: string) { + switch (serviceConfigKey(key)) { + case "hostname": { + yield* stop() + const { hostname: _hostname, ...next } = yield* config() + yield* writeConfig(next) + return + } + case "port": { + yield* stop() + const { port: _port, ...next } = yield* config() + yield* writeConfig(next) + return + } + case "password": { + yield* stop() + const { password: _password, ...next } = yield* config() + yield* writeConfig(next) + return + } + } + }) + const registration = Effect.fnUntraced(function* () { return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration)) }) @@ -131,7 +214,7 @@ export const layer = Layer.effect( return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint")) yield* Effect.try({ try: () => { - spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], { + spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--service"], { detached: true, stdio: "ignore", }).unref() @@ -197,7 +280,7 @@ export const layer = Layer.effect( ) }) - return Service.of({ client, transport, start, status, stop, password, register }) + return Service.of({ client, transport, start, status, stop, password, config, get, set, unset, register }) }), ) From 0f9719a7b57261ac96df6df471727c5483d37162 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 30 Jun 2026 00:28:11 -0400 Subject: [PATCH 20/27] fix(cli): support service autostart setting --- packages/cli/src/services/daemon.ts | 36 ++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index 98c1540388..9cf18222ea 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -36,10 +36,11 @@ const ServiceConfig = Schema.Struct({ hostname: Schema.optional(Schema.String), port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), password: Schema.optional(Schema.String), + autostart: Schema.optional(Schema.Boolean), }) export type ServiceConfig = typeof ServiceConfig.Type -const serviceConfigKeys = ["hostname", "port", "password"] as const +const serviceConfigKeys = ["hostname", "port", "password", "autostart"] as const type ServiceConfigKey = (typeof serviceConfigKeys)[number] function serviceConfigKey(key: string): ServiceConfigKey { @@ -102,6 +103,10 @@ export const layer = Layer.effect( case "password": { return yield* password() } + case "autostart": { + const autostart = (yield* config()).autostart + return autostart === undefined ? "" : String(autostart) + } } }) @@ -124,6 +129,11 @@ export const layer = Layer.effect( yield* password(value) return } + case "autostart": { + if (value !== "true" && value !== "false") throw new Error("Autostart must be true or false") + yield* writeConfig({ ...(yield* config()), autostart: value === "true" }) + return + } } }) @@ -147,6 +157,11 @@ export const layer = Layer.effect( yield* writeConfig(next) return } + case "autostart": { + const { autostart: _autostart, ...next } = yield* config() + yield* writeConfig(next) + return + } } }) @@ -166,6 +181,16 @@ export const layer = Layer.effect( return yield* Effect.fail(new Error("Registered server is not healthy")) }) + const remoteTransport = Effect.fn("cli.daemon.remoteTransport")(function* (input: ServiceConfig) { + const url = serviceURL(input) + const headers = ServerAuth.headers({ password: input.password }) + const response = yield* Effect.tryPromise(() => + createOpencodeClient({ baseUrl: url, headers }).v2.health.get({ signal: AbortSignal.timeout(2_000) }), + ) + if (response.data?.healthy === true) return { url, headers } + return yield* Effect.fail(new Error(`Server is not healthy: ${url}`)) + }) + const compatible = Effect.fnUntraced(function* () { const info = yield* healthy() if (info.version === InstallationVersion) return info @@ -230,6 +255,8 @@ export const layer = Layer.effect( }) const transport = Effect.fn("cli.daemon.transport")(function* () { + const current = yield* config() + if (current.autostart === false) return yield* remoteTransport(current) return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) } }) @@ -286,4 +313,11 @@ export const layer = Layer.effect( export const defaultLayer = layer +function serviceURL(config: ServiceConfig) { + const hostname = config.hostname ?? "127.0.0.1" + const result = new URL(`http://${hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname}`) + result.port = String(config.port ?? 4096) + return result.toString() +} + export * as Daemon from "./daemon" From 524ee8fc03bbdb54799747af9254b85cb10252d3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:53:41 -0500 Subject: [PATCH 21/27] fix(core): gate v2 edit tools by model (#34558) Co-authored-by: Aiden Cline --- packages/core/src/session/runner/llm.ts | 4 +- packages/core/src/tool/registry.ts | 18 +++++-- packages/core/test/lib/tool.ts | 17 +++--- packages/core/test/location-layer.test.ts | 2 - .../test/session-runner-tool-registry.test.ts | 54 ++++++++++++------- packages/core/test/tool-apply-patch.test.ts | 16 +++++- packages/core/test/tool-subagent.test.ts | 6 ++- 7 files changed, 82 insertions(+), 35 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index b34d8e3aa2..089991eed2 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -194,7 +194,9 @@ export const layer = Layer.effect( const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps - const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) + const toolMaterialization = isLastStep + ? undefined + : yield* tools.materialize({ permissions: agent.info?.permissions, model }) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 4119306031..b47c1d6c6a 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -21,11 +21,16 @@ export type ExecuteInput = { } export interface Interface { - readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect + readonly materialize: (input: MaterializeInput) => Effect.Effect /** Internal registration capability exposed publicly only through Tools.Service. */ readonly register: (tools: Readonly>) => Effect.Effect } +export interface MaterializeInput { + readonly model: { readonly id: string; readonly provider: string } + readonly permissions?: PermissionV2.Ruleset +} + export interface Materialization { readonly definitions: ReadonlyArray readonly settle: (input: ExecuteInput) => Effect.Effect @@ -102,14 +107,19 @@ const registryLayer = Layer.effect( }), ) }), - materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions = []) { + materialize: Effect.fn("ToolRegistry.materialize")(function* (input) { const registrations = new Map(applications.entries()) for (const [name, entries] of local) { const registration = entries.at(-1)?.registration if (registration) registrations.set(name, registration) } - for (const [name, registration] of registrations) - if (whollyDisabled(permission(registration.tool, name), permissions)) registrations.delete(name) + // OpenAI/GPT models use apply_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 === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch + if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? [])) + registrations.delete(name) + } return { definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)), settle: (input) => { diff --git a/packages/core/test/lib/tool.ts b/packages/core/test/lib/tool.ts index a711e31184..0639837163 100644 --- a/packages/core/test/lib/tool.ts +++ b/packages/core/test/lib/tool.ts @@ -1,4 +1,5 @@ import { AgentV2 } from "@opencode-ai/core/agent" +import type { PermissionV2 } from "@opencode-ai/core/permission" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { Effect } from "effect" @@ -8,13 +9,17 @@ export const toolIdentity = { assistantMessageID: SessionMessage.ID.make("msg_tool_test"), } +// Default fixture model: a non-OpenAI provider, so edit and write are the materialized edit tools. +export const testModel: ToolRegistry.MaterializeInput["model"] = { id: "claude-test", provider: "anthropic" } + export const toolDefinitions = ( registry: ToolRegistry.Interface, - permissions?: Parameters[0], -) => registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions)) + permissions?: PermissionV2.Ruleset, + model = testModel, +) => registry.materialize({ permissions, model }).pipe(Effect.map((materialized) => materialized.definitions)) -export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) => - registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input))) +export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) => + registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input))) -export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) => - settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result)) +export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) => + settleTool(registry, input, model).pipe(Effect.map((settlement) => settlement.result)) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 35616256d9..9d96add1d2 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -120,7 +120,6 @@ describe("LocationServiceMap", () => { expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false) expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([ "application_context", - "apply_patch", "edit", "glob", "grep", @@ -136,7 +135,6 @@ describe("LocationServiceMap", () => { expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true) expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([ "application_context", - "apply_patch", "edit", "glob", "grep", diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index d286c29285..81d80e6f9f 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -1,12 +1,13 @@ import { describe, expect } from "bun:test" import { Tool } from "@opencode-ai/core/tool/tool" import { AgentV2 } from "@opencode-ai/core/agent" +import type { PermissionV2 } from "@opencode-ai/core/permission" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolRegistry } from "@opencode-ai/core/tool/registry" -import { executeTool, settleTool, toolDefinitions } from "./lib/tool" +import { executeTool, settleTool, testModel, toolDefinitions } from "./lib/tool" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect" import { testEffect } from "./lib/effect" @@ -61,17 +62,11 @@ describe("ToolRegistry", () => { bash: make(), edit: make("edit"), write: make("edit"), - apply_patch: make("edit"), }) - const names = (rules: Parameters[0]) => - toolDefinitions(service, rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name))) + const names = (permissions: PermissionV2.Ruleset) => + toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name))) - expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([ - "bash", - "edit", - "write", - "apply_patch", - ]) + expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["bash", "edit", "write"]) expect( yield* names([ { action: "*", resource: "*", effect: "deny" }, @@ -88,6 +83,27 @@ describe("ToolRegistry", () => { }), ) + it.effect("selects one edit tool family for each model", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + yield* service.register({ + read: make(), + edit: make("edit"), + write: make("edit"), + apply_patch: make("edit"), + }) + const names = (model: ToolRegistry.MaterializeInput["model"]) => + service + .materialize({ model }) + .pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name))) + + expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "apply_patch"]) + expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "apply_patch"]) + expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "apply_patch"]) + expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"]) + }), + ) + it.effect("keeps permission decoration isolated between registrations", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service @@ -183,7 +199,7 @@ describe("ToolRegistry", () => { }), }) expect( - yield* service.materialize().pipe( + yield* service.materialize({ model: testModel }).pipe( Effect.flatMap((materialized) => materialized.settle({ sessionID, @@ -201,7 +217,7 @@ describe("ToolRegistry", () => { Effect.gen(function* () { const service = yield* ToolRegistry.Service yield* service.register({ echo: make() }) - const materialized = yield* service.materialize() + const materialized = yield* service.materialize({ model: testModel }) const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) @@ -331,7 +347,7 @@ describe("ToolRegistry", () => { Effect.gen(function* () { const service = yield* ToolRegistry.Service yield* service.register({ echo: make() }) - const materialized = yield* service.materialize() + const materialized = yield* service.materialize({ model: testModel }) expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" }) }), @@ -342,7 +358,7 @@ describe("ToolRegistry", () => { const service = yield* ToolRegistry.Service const scope = yield* Scope.make() yield* service.register({ echo: make() }).pipe(Scope.provide(scope)) - const materialized = yield* service.materialize() + const materialized = yield* service.materialize({ model: testModel }) yield* Scope.close(scope, Exit.void) expect((yield* materialized.settle(call("echo"))).result).toEqual({ @@ -356,7 +372,7 @@ describe("ToolRegistry", () => { Effect.gen(function* () { const service = yield* ToolRegistry.Service yield* service.register({ first: make(), second: make() }) - const materialized = yield* service.materialize() + const materialized = yield* service.materialize({ model: testModel }) yield* service.register({ first: make() }) expect((yield* materialized.settle(call("first"))).result).toEqual({ @@ -373,7 +389,7 @@ describe("ToolRegistry", () => { yield* service.register({ echo: make() }) const overlay = yield* Scope.make() yield* service.register({ echo: make() }).pipe(Scope.provide(overlay)) - const materialized = yield* service.materialize() + const materialized = yield* service.materialize({ model: testModel }) yield* Scope.close(overlay, Exit.void) expect((yield* materialized.settle(call("echo"))).result).toEqual({ @@ -388,7 +404,7 @@ describe("ToolRegistry", () => { const applications = yield* ApplicationTools.Service const service = yield* ToolRegistry.Service yield* applications.register({ echo: make() }) - const materialized = yield* service.materialize() + const materialized = yield* service.materialize({ model: testModel }) yield* service.register({ echo: make() }) expect((yield* materialized.settle(call("echo"))).result).toEqual({ @@ -405,7 +421,7 @@ describe("ToolRegistry", () => { yield* applications.register({ echo: make() }) const scope = yield* Scope.make() yield* service.register({ echo: make() }).pipe(Scope.provide(scope)) - const materialized = yield* service.materialize() + const materialized = yield* service.materialize({ model: testModel }) yield* Scope.close(scope, Exit.void) expect((yield* materialized.settle(call("echo"))).result).toEqual({ @@ -433,7 +449,7 @@ describe("ToolRegistry", () => { }), }) .pipe(Scope.provide(scope)) - const materialized = yield* service.materialize() + const materialized = yield* service.materialize({ model: testModel }) const settlement = yield* materialized.settle(call("echo")).pipe(Effect.forkChild) yield* Deferred.await(started) yield* Scope.close(scope, Exit.void) diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 9b666f0917..304d062c7c 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -109,6 +109,9 @@ const call = (patchText: string, id = "call-apply-patch") => ({ call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } }, }) +// apply_patch is only materialized for OpenAI/GPT models. +const model = { id: "gpt-5", provider: "openai" } + const exists = (target: string) => Effect.promise(() => fs.stat(target).then( @@ -132,12 +135,15 @@ describe("ApplyPatchTool", () => { Effect.andThen( withTool(tmp.path, (registry) => Effect.gen(function* () { - expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["apply_patch"]) + expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([ + "apply_patch", + ]) const settled = yield* settleTool( registry, call( "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch", ), + model, ) expect(settled.result).toEqual({ type: "text", @@ -207,6 +213,7 @@ describe("ApplyPatchTool", () => { call( "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch", ), + model, ), ).toEqual({ type: "error", value: "apply_patch moves are not supported yet" }) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) @@ -234,6 +241,7 @@ describe("ApplyPatchTool", () => { yield* executeTool( registry, call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`), + model, ), ).toMatchObject({ type: "text" }) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) @@ -270,6 +278,7 @@ describe("ApplyPatchTool", () => { call( `*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`, ), + model, ), ).toMatchObject({ type: "text" }) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"]) @@ -301,6 +310,7 @@ describe("ApplyPatchTool", () => { call( "*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch", ), + model, ), ).toEqual({ type: "error", value: "Unable to apply patch at missing.txt" }) expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false) @@ -325,6 +335,7 @@ describe("ApplyPatchTool", () => { yield* executeTool( registry, call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"), + model, ), ).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("sentinel\n") @@ -350,6 +361,7 @@ describe("ApplyPatchTool", () => { yield* executeTool( registry, call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"), + model, ), ).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n") @@ -377,6 +389,7 @@ describe("ApplyPatchTool", () => { yield* executeTool( registry, call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"), + model, ).pipe(Effect.exit), ), ).toBe(true) @@ -408,6 +421,7 @@ describe("ApplyPatchTool", () => { const run = yield* executeTool( registry, call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"), + model, ).pipe(Effect.forkChild) yield* Deferred.await(removeStarted!) const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild) diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 70dd693244..1a1f2e8705 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -23,7 +23,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -import { executeTool, settleTool, toolIdentity } from "./lib/tool" +import { executeTool, settleTool, testModel, toolIdentity } from "./lib/tool" const childText = "child final response" const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") }) @@ -142,7 +142,9 @@ describe("SubagentTool", () => { const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) - expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name) + expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain( + SubagentTool.name, + ) expect( yield* executeTool(registry, { sessionID: parent.id, From 23adaaaeab653b3d046cc6a7a1d3591a7fcf4b75 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 30 Jun 2026 01:14:44 -0400 Subject: [PATCH 22/27] feat(core): add native skill activation --- AGENTS.md | 1 + packages/cli/src/services/daemon.ts | 10 +- packages/cli/test/daemon.test.ts | 30 +++++ .../client/src/generated-effect/client.ts | 108 ++++++++------- packages/client/src/generated/client.ts | 14 ++ packages/client/src/generated/types.ts | 103 +++++++++++++++ packages/core/src/plugin/skill.ts | 90 ++++++++++++- packages/core/src/plugin/skill/report.md | 125 ++++++++++++++++++ packages/core/src/session.ts | 25 +++- packages/core/src/session/compaction.ts | 1 + packages/core/src/session/message-updater.ts | 11 ++ packages/core/src/session/projector.ts | 9 ++ .../core/src/session/runner/to-llm-message.ts | 2 + packages/core/test/plugin/skill.test.ts | 33 ++++- packages/core/test/session-create.test.ts | 1 - packages/protocol/src/errors.ts | 9 ++ packages/protocol/src/groups/session.ts | 21 +++ packages/schema/src/session-event.ts | 16 +++ packages/schema/src/session-message.ts | 11 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 41 ++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 125 ++++++++++++++++++ packages/server/src/handlers/session.ts | 25 ++++ packages/tui/src/component/dialog-skill.tsx | 16 ++- .../tui/src/component/prompt/autocomplete.tsx | 24 +++- packages/tui/src/component/prompt/index.tsx | 18 ++- packages/tui/src/routes/session/index.tsx | 21 ++- 26 files changed, 814 insertions(+), 76 deletions(-) create mode 100644 packages/cli/test/daemon.test.ts create mode 100644 packages/core/src/plugin/skill/report.md diff --git a/AGENTS.md b/AGENTS.md index cd2327e888..649f0ae055 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ - To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly. - Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server. +- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required. - The default branch in this repo is `dev`. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. diff --git a/packages/cli/src/services/daemon.ts b/packages/cli/src/services/daemon.ts index 9cf18222ea..582df7464f 100644 --- a/packages/cli/src/services/daemon.ts +++ b/packages/cli/src/services/daemon.ts @@ -56,9 +56,11 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem - const directory = Global.Path.state - const file = path.join(directory, InstallationChannel === "local" ? "service-local.json" : "service.json") - const configFile = path.join(Global.Path.config, "service.json") + const global = yield* Global.Service + const directory = global.state + const filename = InstallationChannel === "local" ? "service-local.json" : "service.json" + const file = path.join(directory, filename) + const configFile = path.join(global.config, filename) const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig)) @@ -311,7 +313,7 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer +export const defaultLayer = layer.pipe(Layer.provide(Global.defaultLayer)) function serviceURL(config: ServiceConfig) { const hostname = config.hostname ?? "127.0.0.1" diff --git a/packages/cli/test/daemon.test.ts b/packages/cli/test/daemon.test.ts new file mode 100644 index 0000000000..138544fdf4 --- /dev/null +++ b/packages/cli/test/daemon.test.ts @@ -0,0 +1,30 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { Global } from "@opencode-ai/core/global" +import { expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Daemon } from "../src/services/daemon" + +test("local channel stores service config with the local service filename", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-daemon-")) + try { + await Effect.runPromise( + Effect.gen(function* () { + const daemon = yield* Daemon.Service + yield* daemon.set("autostart", "false") + }).pipe( + Effect.provide(Daemon.layer), + Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })), + Effect.provide(NodeFileSystem.layer), + ), + ) + expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({ + autostart: false, + }) + expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 59f12e225d..465a245d1a 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -144,23 +144,36 @@ const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Inp Effect.map((value) => value.data), ) -type Endpoint3_9Request = Parameters[0] -type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] } +type Endpoint3_9Request = Parameters[0] +type Endpoint3_9Input = { + readonly sessionID: Endpoint3_9Request["params"]["sessionID"] + readonly id?: Endpoint3_9Request["payload"]["id"] + readonly skill: Endpoint3_9Request["payload"]["skill"] + readonly resume?: Endpoint3_9Request["payload"]["resume"] +} const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) => - raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.skill"]({ + params: { sessionID: input["sessionID"] }, + payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, + }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_10Request = Parameters[0] +type Endpoint3_10Request = Parameters[0] type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] } const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_11Request = Parameters[0] +type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] } +const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_11Request = Parameters[0] -type Endpoint3_11Input = { - readonly sessionID: Endpoint3_11Request["params"]["sessionID"] - readonly messageID: Endpoint3_11Request["payload"]["messageID"] - readonly files?: Endpoint3_11Request["payload"]["files"] +type Endpoint3_12Request = Parameters[0] +type Endpoint3_12Input = { + readonly sessionID: Endpoint3_12Request["params"]["sessionID"] + readonly messageID: Endpoint3_12Request["payload"]["messageID"] + readonly files?: Endpoint3_12Request["payload"]["files"] } -const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) => +const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -169,42 +182,42 @@ const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11I Effect.map((value) => value.data), ) -type Endpoint3_12Request = Parameters[0] -type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] } -const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint3_13Request = Parameters[0] +type Endpoint3_13Request = Parameters[0] type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] } const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_14Request = Parameters[0] +type Endpoint3_14Request = Parameters[0] type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] } const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint3_15Request = Parameters[0] +type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] } +const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint3_15Request = Parameters[0] -type Endpoint3_15Input = { - readonly sessionID: Endpoint3_15Request["params"]["sessionID"] - readonly limit?: Endpoint3_15Request["query"]["limit"] - readonly after?: Endpoint3_15Request["query"]["after"] +type Endpoint3_16Request = Parameters[0] +type Endpoint3_16Input = { + readonly sessionID: Endpoint3_16Request["params"]["sessionID"] + readonly limit?: Endpoint3_16Request["query"]["limit"] + readonly after?: Endpoint3_16Request["query"]["after"] } -const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) => +const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) => raw["session.history"]({ params: { sessionID: input["sessionID"] }, query: { limit: input["limit"], after: input["after"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_16Request = Parameters[0] -type Endpoint3_16Input = { - readonly sessionID: Endpoint3_16Request["params"]["sessionID"] - readonly after?: Endpoint3_16Request["query"]["after"] +type Endpoint3_17Request = Parameters[0] +type Endpoint3_17Input = { + readonly sessionID: Endpoint3_17Request["params"]["sessionID"] + readonly after?: Endpoint3_17Request["query"]["after"] } -const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) => +const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) => Stream.unwrap( raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe( Effect.mapError(mapClientError), @@ -212,17 +225,17 @@ const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16I ), ) -type Endpoint3_17Request = Parameters[0] -type Endpoint3_17Input = { readonly sessionID: Endpoint3_17Request["params"]["sessionID"] } -const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) => +type Endpoint3_18Request = Parameters[0] +type Endpoint3_18Input = { readonly sessionID: Endpoint3_18Request["params"]["sessionID"] } +const Endpoint3_18 = (raw: RawClient["server.session"]) => (input: Endpoint3_18Input) => raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint3_18Request = Parameters[0] -type Endpoint3_18Input = { - readonly sessionID: Endpoint3_18Request["params"]["sessionID"] - readonly messageID: Endpoint3_18Request["params"]["messageID"] +type Endpoint3_19Request = Parameters[0] +type Endpoint3_19Input = { + readonly sessionID: Endpoint3_19Request["params"]["sessionID"] + readonly messageID: Endpoint3_19Request["params"]["messageID"] } -const Endpoint3_18 = (raw: RawClient["server.session"]) => (input: Endpoint3_18Input) => +const Endpoint3_19 = (raw: RawClient["server.session"]) => (input: Endpoint3_19Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -238,16 +251,17 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({ switchModel: Endpoint3_6(raw), rename: Endpoint3_7(raw), prompt: Endpoint3_8(raw), - compact: Endpoint3_9(raw), - wait: Endpoint3_10(raw), - stage: Endpoint3_11(raw), - clear: Endpoint3_12(raw), - commit: Endpoint3_13(raw), - context: Endpoint3_14(raw), - history: Endpoint3_15(raw), - events: Endpoint3_16(raw), - interrupt: Endpoint3_17(raw), - message: Endpoint3_18(raw), + skill: Endpoint3_9(raw), + compact: Endpoint3_10(raw), + wait: Endpoint3_11(raw), + stage: Endpoint3_12(raw), + clear: Endpoint3_13(raw), + commit: Endpoint3_14(raw), + context: Endpoint3_15(raw), + history: Endpoint3_16(raw), + events: Endpoint3_17(raw), + interrupt: Endpoint3_18(raw), + message: Endpoint3_19(raw), }) type Endpoint4_0Request = Parameters[0] diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index a3402e0c4c..7ea6525fae 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -21,6 +21,8 @@ import type { SessionRenameOutput, SessionPromptInput, SessionPromptOutput, + SessionSkillInput, + SessionSkillOutput, SessionCompactInput, SessionCompactOutput, SessionWaitInput, @@ -427,6 +429,18 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + skill: (input: SessionSkillInput, requestOptions?: RequestOptions) => + request( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/skill`, + body: { id: input["id"], skill: input["skill"], resume: input["resume"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index d09c3bdcfd..79234985d7 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -50,6 +50,14 @@ export type ConflictError = { export const isConflictError = (value: unknown): value is ConflictError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError" +export type SkillNotFoundError = { + readonly _tag: "SkillNotFoundError" + readonly skill: string + readonly message: string +} +export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError" + export type SessionBusyError = { readonly _tag: "SessionBusyError" readonly sessionID: string @@ -540,6 +548,27 @@ export type SessionPromptOutput = { } }["data"] +export type SessionSkillInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { + readonly id?: string | undefined + readonly skill: string + readonly resume?: boolean | undefined + }["id"] + readonly skill: { + readonly id?: string | undefined + readonly skill: string + readonly resume?: boolean | undefined + }["skill"] + readonly resume?: { + readonly id?: string | undefined + readonly skill: string + readonly resume?: boolean | undefined + }["resume"] +} + +export type SessionSkillOutput = void + export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionCompactOutput = void @@ -629,6 +658,14 @@ export type SessionContextOutput = { readonly type: "system" readonly text: string } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "skill" + readonly name: string + readonly text: string + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -882,6 +919,20 @@ export type SessionHistoryOutput = { readonly text: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.skill.activated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly name: string + readonly text: string + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -1361,6 +1412,20 @@ export type SessionEventsOutput = readonly text: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.skill.activated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly name: string + readonly text: string + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } @@ -1749,6 +1814,14 @@ export type SessionMessageOutput = { readonly type: "system" readonly text: string } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "skill" + readonly name: string + readonly text: string + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -1921,6 +1994,14 @@ export type MessageListOutput = { readonly type: "system" readonly text: string } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly time: { readonly created: number } + readonly type: "skill" + readonly name: string + readonly text: string + } | { readonly id: string readonly metadata?: { readonly [x: string]: JsonValue } @@ -2711,6 +2792,14 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: {} } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "agent.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: {} + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } @@ -3392,6 +3481,20 @@ export type EventSubscribeOutput = readonly text: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.skill.activated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly name: string + readonly text: string + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index ea723dd89d..c9eca99118 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -6,26 +6,112 @@ import { define } from "./internal" import { Effect } from "effect" import { AbsolutePath } from "../schema" import { SkillV2 } from "../skill" +import { InstallationChannel, InstallationVersion } from "../installation/version" +import { Config } from "../config" +import { Location } from "../location" +import { FSUtil } from "../fs-util" +import os from "os" +import path from "path" +import { fileURLToPath } from "url" import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } +import reportContent from "./skill/report.md" with { type: "text" } export const CustomizeOpencodeContent = customizeOpencodeContent +export const ReportContent = reportContent + +const CUSTOMIZE_OPENCODE_DESCRIPTION = + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." +const REPORT_DESCRIPTION = + "Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI." export const Plugin = define({ id: "skill", effect: Effect.fn(function* (ctx) { + const reportContent = yield* reportContentWithDiagnostics() yield* ctx.skill.transform((draft) => { draft.source( SkillV2.EmbeddedSource.make({ type: "embedded", skill: SkillV2.Info.make({ name: "customize-opencode", - description: - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", + description: CUSTOMIZE_OPENCODE_DESCRIPTION, location: AbsolutePath.make("/builtin/customize-opencode.md"), content: CustomizeOpencodeContent, }), }), ) + draft.source( + SkillV2.EmbeddedSource.make({ + type: "embedded", + skill: SkillV2.Info.make({ + name: "report", + description: REPORT_DESCRIPTION, + slash: true, + location: AbsolutePath.make("/builtin/report.md"), + content: reportContent, + }), + }), + ) }) }), }) + +const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* () { + const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"])) + return [ + ReportContent, + "", + "## Runtime Diagnostics Snapshot", + "", + "These values were captured when the built-in report skill was registered. Verify them before publishing.", + "", + `- opencode version: ${InstallationVersion}`, + `- install/channel: ${InstallationChannel}`, + `- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`, + `- Terminal: ${terminal()}`, + `- Shell: ${shell()}`, + `- Active plugins: ${plugins.length === 0 ? "None found in config" : plugins.join(", ")}`, + ].join("\n") +}) + +const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () { + const config = yield* Config.Service + const fs = yield* FSUtil.Service + const location = yield* Location.Service + return yield* Effect.forEach(yield* config.entries(), (entry) => { + if (entry.type === "document") { + const directory = entry.path ? path.dirname(entry.path) : location.directory + return Effect.succeed( + (entry.info.plugins ?? []).map((item) => { + const ref = typeof item === "string" ? { package: item } : item + if (ref.package.startsWith("file://")) return fileURLToPath(ref.package) + if (ref.package.startsWith("./") || ref.package.startsWith("../")) return path.resolve(directory, ref.package) + return ref.package + }), + ) + } + return fs + .glob("{plugin,plugins}/*.{ts,js}", { + cwd: entry.path, + absolute: true, + include: "file", + dot: true, + symlink: true, + }) + .pipe(Effect.orElseSucceed(() => [])) + }).pipe(Effect.map((items) => items.flat().toSorted())) +}) + +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" +} + +function shell() { + return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set" +} diff --git a/packages/core/src/plugin/skill/report.md b/packages/core/src/plugin/skill/report.md new file mode 100644 index 0000000000..af5aa26b48 --- /dev/null +++ b/packages/core/src/plugin/skill/report.md @@ -0,0 +1,125 @@ + + +# Report an opencode Issue + +Use this skill when the user wants to report an opencode issue or bug. Your job +is to turn the user's problem into a useful GitHub issue with standard +diagnostics plus the context needed to reproduce and resolve it. + +## Workflow + +1. Collect the standard diagnostics below. +2. Ask only for missing details that are necessary to reproduce or understand + impact. +3. Draft the issue in the standard format below. +4. Publish it with GitHub CLI after the user confirms the title and body. + +Do not publish an issue without user confirmation. If GitHub CLI is not +installed or not authenticated, explain the blocker and provide the exact issue +title/body for the user. + +## Standard Diagnostics + +Collect these values when possible: + +- opencode version: run `opencode --version` or `opencode2 --version`, + depending on the executable in use. +- Operating system: run `uname -a` on Unix-like systems, or `ver` on Windows. +- Terminal: inspect `$TERM`, `$TERM_PROGRAM`, `$COLORTERM`, and any obvious + terminal app context the user provides. +- Shell: inspect `$SHELL` on Unix-like systems, or `%COMSPEC%`/`$ComSpec` on + Windows when relevant. +- Install/channel context: include whether this appears to be local, dev, beta, + or release if the version output or environment reveals it. +- Active plugins: inspect opencode config for configured plugins when possible. + Check likely config locations such as `opencode.json`, `opencode.jsonc`, + `.opencode/opencode.json`, and `~/.config/opencode/opencode.json`. Record + configured plugin entries, local plugin files under `.opencode/plugin/` or + `.opencode/plugins/`, and note if plugin status could not be determined. + +If a diagnostic command fails, include `Unavailable` with the reason instead of +guessing. + +## User-Specific Context + +Capture the details that make the issue actionable: + +- What the user was trying to do. +- What happened. +- What the user expected to happen. +- Reproduction steps, ideally minimal and numbered. +- Relevant logs, stack traces, screenshots, terminal output, or config snippets. +- Whether the issue is reproducible consistently, intermittently, or only once. +- Recent changes that may be related, such as updating opencode, changing + config, installing a plugin, changing terminal, or switching workspace. +- Workarounds tried and whether they helped. + +Avoid pasting secrets. Redact tokens, API keys, private URLs, usernames, and +project-specific confidential data unless the user explicitly says it is safe. + +## Issue Format + +Use this exact structure unless the repository issue template requires +otherwise: + +```markdown +## Summary + + + +## Environment + +- opencode version: +- OS: +- Terminal: +- Shell: +- Install/channel: +- Active plugins: + +## Reproduction + +1. +2. +3. + +## Expected Behavior + + + +## Actual Behavior + + + +## Additional Context + + +``` + +Keep the title short and searchable. Prefer the form: + +```text +: +``` + +Examples: `tui: skills dialog crashes outside location provider`, +`cli: local service config writes release filename`. + +## Publishing With GitHub CLI + +Use GitHub CLI from the repository checkout when available: + +```sh +gh issue create --title "" --body-file <file> +``` + +Write the body to a temporary markdown file first so quoting, newlines, logs, +and code fences are preserved. If the issue belongs in a specific repository, +use `--repo owner/name`. If labels are obvious and the repo accepts them, add +`--label bug`; otherwise omit labels rather than guessing. + +After publishing, report the created issue URL to the user and mention any +diagnostics that were unavailable. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 83cc9ffca1..252f9ca0b2 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -38,6 +38,7 @@ import { SessionRevert } from "./session/revert" import { Revert } from "@opencode-ai/schema/revert" import { FSUtil } from "./fs-util" import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" +import { SkillV2 } from "./skill" export const RevertState = Revert.State export type RevertState = Revert.State @@ -115,6 +116,9 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", { sessionID: SessionSchema.ID, }) {} +export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", { + skill: Schema.String, +}) {} export const MessageNotFoundError = SessionRevert.MessageNotFoundError export type MessageNotFoundError = SessionRevert.MessageNotFoundError @@ -124,6 +128,7 @@ export type Error = | OperationUnavailableError | PromptConflictError | BusyError + | SkillNotFoundError | MessageNotFoundError export interface Interface { @@ -176,11 +181,11 @@ export interface Interface { resume?: boolean }) => Effect.Effect<void, OperationUnavailableError> readonly skill: (input: { - id?: EventV2.ID + id?: SessionMessage.ID sessionID: SessionSchema.ID skill: string resume?: boolean - }) => Effect.Effect<void, OperationUnavailableError> + }) => Effect.Effect<void, NotFoundError | SkillNotFoundError> readonly compact: ( input: CompactInput, ) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError> @@ -440,8 +445,20 @@ export const layer = Layer.effect( shell: Effect.fn("V2Session.shell")(function* () { return yield* new OperationUnavailableError({ operation: "shell" }) }), - skill: Effect.fn("V2Session.skill")(function* () { - return yield* new OperationUnavailableError({ operation: "skill" }) + 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.name === input.skill) + if (!skill) return yield* new SkillNotFoundError({ skill: input.skill }) + yield* events.publish(SessionEvent.Skill.Activated, { + sessionID: input.sessionID, + messageID: input.id ?? SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + name: skill.name, + text: skill.content, + }) + if (input.resume !== false) + yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) }), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { yield* result.get(input.sessionID) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index de12ef50ed..99c45cbb52 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -130,6 +130,7 @@ const serialize = (message: SessionMessage.Message) => { } 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)}` return "" } diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index cfb424620d..1e12498047 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -159,6 +159,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { }), ) }, + "session.next.skill.activated": (event) => { + return adapter.appendMessage( + SessionMessage.Skill.make({ + id: event.data.messageID, + type: "skill", + name: event.data.name, + text: event.data.text, + time: { created: event.data.timestamp }, + }), + ) + }, "session.next.shell.started": (event) => { return adapter.appendMessage( SessionMessage.Shell.make({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index a9a069cf6a..52f2554dd0 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -584,6 +584,15 @@ export const layer = Layer.effectDiscard( ) yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) + yield* events.project(SessionEvent.Skill.Activated, (event) => + insertMessage(db, event, { + id: event.data.messageID, + type: "skill", + name: event.data.name, + text: event.data.text, + time: { created: event.data.timestamp }, + }), + ) 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)) diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index b2b1af5d30..17e13dc767 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -131,6 +131,8 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] ] case "synthetic": return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })] + case "skill": + return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })] case "system": return [Message.system(message.text)] case "shell": diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index 61bee856b9..c1a94dc60a 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -1,25 +1,50 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { NodeFileSystem } from "@effect/platform-node" +import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Location } from "@opencode-ai/core/location" import { SkillPlugin } from "@opencode-ai/core/plugin/skill" +import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" +import { Effect } from "effect" +import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { host } from "./host" const it = testEffect(AppNodeBuilder.build(SkillV2.node)) describe("SkillPlugin.Plugin", () => { - it.effect("registers the built-in customize-opencode skill", () => + it.effect("registers built-in skills", () => Effect.gen(function* () { const skill = yield* SkillV2.Service - yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })) + yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })).pipe( + Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })), + Effect.provideService( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), + ), + Effect.provide(FSUtil.defaultLayer), + Effect.provide(NodeFileSystem.layer), + ) + const skills = yield* skill.list() + const report = skills.find((item) => item.name === "report") - expect(yield* skill.list()).toContainEqual( + expect(skills).toContainEqual( expect.objectContaining({ name: "customize-opencode", description: expect.stringContaining("opencode's own configuration"), }), ) + expect(skills).toContainEqual( + expect.objectContaining({ + name: "report", + description: expect.stringContaining("opencode issue"), + }), + ) + expect(report?.slash).toBe(true) + expect(report?.content).toContain(`- opencode version: ${InstallationVersion}`) }), ) }) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 16bdfd68b4..aaff515d12 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -444,7 +444,6 @@ describe("SessionV2.create", () => { ) expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") - expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill") }), ) diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 27478af786..7f6893bae0 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -80,6 +80,15 @@ export class MessageNotFoundError extends Schema.TaggedErrorClass<MessageNotFoun { httpApiStatus: 404 }, ) {} +export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()( + "SkillNotFoundError", + { + skill: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + export class InvalidCursorError extends Schema.TaggedErrorClass<InvalidCursorError>()( "InvalidCursorError", { message: Schema.String }, diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 5706e14dbf..291a75f7f0 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -15,6 +15,7 @@ import { ServiceUnavailableError, SessionBusyError, SessionNotFoundError, + SkillNotFoundError, UnknownError, } from "../errors" import { Agent } from "@opencode-ai/schema/agent" @@ -256,6 +257,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.skill", "/api/session/:sessionID/skill", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: SessionMessage.ID.pipe(Schema.optional), + skill: Schema.String, + resume: Schema.Boolean.pipe(Schema.optional), + }), + success: HttpApiSchema.NoContent, + error: [SessionNotFoundError, SkillNotFoundError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.skill", + summary: "Activate skill", + description: "Activate a skill for a session by appending a skill message and resuming execution.", + }), + ), + ) .add( HttpApiEndpoint.post("session.compact", "/api/session/:sessionID/compact", { params: { sessionID: Session.ID }, diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index a66881a8ad..a38b240e07 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -141,6 +141,20 @@ export const Synthetic = Event.define({ }) export type Synthetic = typeof Synthetic.Type +export namespace Skill { + export const Activated = Event.define({ + type: "session.next.skill.activated", + ...options, + schema: { + ...Base, + messageID: SessionMessage.ID, + name: Schema.String, + text: Schema.String, + }, + }) + export type Activated = typeof Activated.Type +} + export namespace Shell { export const Started = Event.define({ type: "session.next.shell.started", @@ -476,6 +490,7 @@ export const DurableDefinitions = Event.inventory( PromptAdmitted, ContextUpdated, Synthetic, + Skill.Activated, Shell.Started, Shell.Ended, Step.Started, @@ -509,6 +524,7 @@ export const Definitions = Event.inventory( PromptAdmitted, ContextUpdated, Synthetic, + Skill.Activated, Shell.Started, Shell.Ended, Step.Started, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 58ff532063..63bdf5e7aa 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -65,6 +65,14 @@ export const System = Schema.Struct({ text: Schema.String, }).annotate({ identifier: "Session.Message.System" }) +export interface Skill extends Schema.Schema.Type<typeof Skill> {} +export const Skill = Schema.Struct({ + ...Base, + type: Schema.Literal("skill"), + name: Schema.String, + text: Schema.String, +}).annotate({ identifier: "Session.Message.Skill" }) + export interface Shell extends Schema.Schema.Type<typeof Shell> {} export const Shell = Schema.Struct({ ...Base, @@ -203,11 +211,12 @@ export const Message = Schema.Union([ User, Synthetic, System, + Skill, Shell, Assistant, Compaction, ]) .pipe(Schema.toTaggedUnion("type")) .annotate({ identifier: "Session.Message" }) -export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Shell | Assistant | Compaction +export type Message = AgentSwitched | ModelSwitched | User | Synthetic | System | Skill | Shell | Assistant | Compaction export type Type = Message["type"] diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 0a261778bf..f567f48ed1 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -387,6 +387,8 @@ import type { V2SessionRevertCommitResponses, V2SessionRevertStageErrors, V2SessionRevertStageResponses, + V2SessionSkillErrors, + V2SessionSkillResponses, V2SessionSwitchAgentErrors, V2SessionSwitchAgentResponses, V2SessionSwitchModelErrors, @@ -5745,6 +5747,45 @@ export class Session3 extends HeyApiClient { }) } + /** + * Activate skill + * + * Activate a skill for a session by appending a skill message and resuming execution. + */ + public skill<ThrowOnError extends boolean = false>( + parameters: { + sessionID: string + id?: string + skill?: string + resume?: boolean + }, + options?: Options<never, ThrowOnError>, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "skill" }, + { in: "body", key: "resume" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post<V2SessionSkillResponses, V2SessionSkillErrors, ThrowOnError>({ + url: "/api/session/{sessionID}/skill", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Compact session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3902f04c99..57487cf2f7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -26,6 +26,7 @@ export type Event = | EventSessionNextPromptAdmitted | EventSessionNextContextUpdated | EventSessionNextSynthetic + | EventSessionNextSkillActivated | EventSessionNextShellStarted | EventSessionNextShellEnded | EventSessionNextStepStarted @@ -940,6 +941,17 @@ export type GlobalEvent = { text: string } } + | { + id: string + type: "session.next.skill.activated" + properties: { + timestamp: number + sessionID: string + messageID: string + name: string + text: string + } + } | { id: string type: "session.next.shell.started" @@ -1690,6 +1702,7 @@ export type GlobalEvent = { | SyncEventSessionNextPromptAdmitted | SyncEventSessionNextContextUpdated | SyncEventSessionNextSynthetic + | SyncEventSessionNextSkillActivated | SyncEventSessionNextShellStarted | SyncEventSessionNextShellEnded | SyncEventSessionNextStepStarted @@ -2794,6 +2807,12 @@ export type ConflictError = { resource?: string } +export type SkillNotFoundError = { + _tag: "SkillNotFoundError" + skill: string + message: string +} + export type ServiceUnavailableError = { _tag: "ServiceUnavailableError" message: string @@ -2816,6 +2835,7 @@ export type SessionDurableEvent = | SessionNextPromptAdmitted | SessionNextContextUpdated | SessionNextSynthetic + | SessionNextSkillActivated | SessionNextShellStarted | SessionNextShellEnded | SessionNextStepStarted @@ -2970,6 +2990,7 @@ export type V2Event = | SessionNextPromptAdmitted | SessionNextContextUpdated | SessionNextSynthetic + | SessionNextSkillActivated | SessionNextShellStarted | SessionNextShellEnded | SessionNextStepStarted @@ -3578,6 +3599,24 @@ export type SyncEventSessionNextSynthetic = { } } +export type SyncEventSessionNextSkillActivated = { + type: "sync" + id: string + syncEvent: { + type: "session.next.skill.activated.1" + id: string + seq: number + aggregateID: string + data: { + timestamp: number + sessionID: string + messageID: string + name: string + text: string + } + } +} + export type SyncEventSessionNextShellStarted = { type: "sync" id: string @@ -4170,6 +4209,19 @@ export type SessionMessageSystem = { text: string } +export type SessionMessageSkill = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "skill" + name: string + text: string +} + export type SessionMessageShell = { id: string metadata?: { @@ -4319,6 +4371,7 @@ export type SessionMessage = | SessionMessageUser | SessionMessageSynthetic | SessionMessageSystem + | SessionMessageSkill | SessionMessageShell | SessionMessageAssistant | SessionMessageCompaction @@ -4504,6 +4557,27 @@ export type SessionNextSynthetic = { } } +export type SessionNextSkillActivated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.skill.activated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + messageID: string + name: string + text: string + } +} + export type SessionNextShellStarted = { id: string metadata?: { @@ -6631,6 +6705,18 @@ export type EventSessionNextSynthetic = { } } +export type EventSessionNextSkillActivated = { + id: string + type: "session.next.skill.activated" + properties: { + timestamp: number + sessionID: string + messageID: string + name: string + text: string + } +} + export type EventSessionNextShellStarted = { id: string type: "session.next.shell.started" @@ -12002,6 +12088,45 @@ export type V2SessionPromptResponses = { export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] +export type V2SessionSkillData = { + body: { + id?: string + skill: string + resume?: boolean + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/skill" +} + +export type V2SessionSkillErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | SkillNotFoundError + */ + 404: SkillNotFoundError | SessionNotFoundError +} + +export type V2SessionSkillError = V2SessionSkillErrors[keyof V2SessionSkillErrors] + +export type V2SessionSkillResponses = { + /** + * <No Content> + */ + 204: void +} + +export type V2SessionSkillResponse = V2SessionSkillResponses[keyof V2SessionSkillResponses] + export type V2SessionCompactData = { body?: never path: { diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 9be10c57ba..d026ca8d16 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -10,6 +10,7 @@ import { ServiceUnavailableError, SessionBusyError, SessionNotFoundError, + SkillNotFoundError, UnknownError, } from "@opencode-ai/protocol/errors" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -214,6 +215,30 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.skill", + Effect.fn(function* (ctx) { + yield* session.skill({ + sessionID: ctx.params.sessionID, + id: ctx.payload.id, + skill: ctx.payload.skill, + resume: ctx.payload.resume, + }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Session.SkillNotFoundError", (error) => + Effect.fail(new SkillNotFoundError({ skill: error.skill, message: `Skill not found: ${error.skill}` })), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.compact", Effect.fn(function* (ctx) { diff --git a/packages/tui/src/component/dialog-skill.tsx b/packages/tui/src/component/dialog-skill.tsx index e962a6e7c3..61c173a384 100644 --- a/packages/tui/src/component/dialog-skill.tsx +++ b/packages/tui/src/component/dialog-skill.tsx @@ -2,26 +2,32 @@ import { TextAttributes } from "@opentui/core" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { createResource, createMemo, createSignal } from "solid-js" import { useDialog } from "../ui/dialog" -import { useSDK } from "../context/sdk" import { useTheme } from "../context/theme" import { errorMessage } from "../util/error" +import { useData } from "../context/data" +import type { LocationRef } from "@opencode-ai/sdk/v2" export type DialogSkillProps = { + location?: LocationRef onSelect: (skill: string) => void } export function DialogSkill(props: DialogSkillProps) { const dialog = useDialog() - const sdk = useSDK() + const data = useData() const { theme } = useTheme() dialog.setSize("large") const [loadError, setLoadError] = createSignal<unknown>() const [skills] = createResource(() => - sdk.client.app - .skills({}, { throwOnError: true }) - .then((result) => result.data ?? []) + Promise.resolve() + .then(async () => { + const current = data.location.skill.list(props.location) + if (current) return current + await data.location.skill.refresh(props.location) + return data.location.skill.list(props.location) ?? [] + }) // Catch so the rejected resource never reaches the memo below: reading // skills() in an errored state re-throws and tears down the dialog. .catch((error) => { diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 6d3e341250..fb9d1537e9 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -443,12 +443,12 @@ export function Autocomplete(props: { const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [...slashes()] + const commandNames = new Set<string>() - for (const serverCommand of sync.data.command) { - if (serverCommand.source === "skill") continue - const label = serverCommand.source === "mcp" ? ":mcp" : "" + for (const serverCommand of data.location.command.list(location()) ?? []) { + commandNames.add(serverCommand.name) results.push({ - display: "/" + serverCommand.name + label, + display: "/" + serverCommand.name, description: serverCommand.description, onSelect: () => { const newText = "/" + serverCommand.name + " " @@ -460,6 +460,22 @@ export function Autocomplete(props: { }) } + for (const skill of data.location.skill + .list(location()) + ?.filter((skill) => skill.slash === true && !commandNames.has(skill.name)) ?? []) { + results.push({ + display: "/" + skill.name, + description: skill.description, + onSelect: () => { + const newText = "/" + skill.name + " " + const cursor = props.input().logicalCursor + props.input().deleteRange(0, 0, cursor.row, cursor.col) + props.input().insertText(newText) + props.input().cursorOffset = Bun.stringWidth(newText) + }, + }) + } + results.sort((a, b) => a.display.localeCompare(b.display)) const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 288fdaf710..e5d2670a00 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -56,6 +56,7 @@ import { usePromptWorkspace } from "./workspace" import { usePromptMove } from "./move" import { readLocalAttachment } from "./local-attachment" import { useData } from "../../context/data" +import { useLocation } from "../../context/location" export type PromptProps = { sessionID?: string @@ -154,6 +155,7 @@ export function Prompt(props: PromptProps) { const project = useProject() const sync = useSync() const data = useData() + const currentLocation = useLocation() const tuiConfig = useTuiConfig() const dialog = useDialog() const toast = useToast() @@ -533,6 +535,7 @@ export function Prompt(props: PromptProps) { run: () => { dialog.replace(() => ( <DialogSkill + location={currentLocation()} onSelect={(skill) => { input.setText(`/${skill} `) setStore("prompt", { @@ -1088,7 +1091,9 @@ export function Prompt(props: PromptProps) { setStore("mode", "normal") } else if ( inputText.startsWith("/") && - sync.data.command.some((x) => x.name === inputText.split("\n")[0].split(" ")[0].slice(1)) + (data.location.command.list(currentLocation()) ?? []).some( + (command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1), + ) ) { move.startSubmit() // Parse command from first line, preserve multi-line content in arguments @@ -1107,6 +1112,17 @@ export function Prompt(props: PromptProps) { variant, parts: nonTextParts.filter((x) => x.type === "file"), }) + } else if ( + inputText.startsWith("/") && + (data.location.skill.list(currentLocation()) ?? []).some( + (skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1), + ) + ) { + move.startSubmit() + void sdk.api.session.skill({ + sessionID, + skill: inputText.split("\n")[0].split(" ")[0].slice(1), + }) } else { move.startSubmit() if (!session) { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index b7ff581709..92a24ffcbb 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1049,8 +1049,10 @@ function SessionMessageView(props: { message: SessionMessage }) { <Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}> <SessionSwitchMessageV2 message={props.message} /> </Match> - <Match when={props.message.type === "system" || props.message.type === "synthetic"}> - <SessionNoticeMessageV2 message={props.message} /> + <Match when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}> + <Show when={props.message.type === "skill"} fallback={<SessionNoticeMessageV2 message={props.message} />}> + <SessionSkillMessage message={props.message as Extract<SessionMessage, { type: "skill" }>} /> + </Show> </Match> <Match when={props.message.type === "compaction"}> <CompactionMessage /> @@ -1211,13 +1213,26 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) { function SessionNoticeMessageV2(props: { message: SessionMessage }) { const { theme } = useTheme() + const text = () => { + if (props.message.type === "system" || props.message.type === "synthetic") return props.message.text + return "" + } return ( <text fg={theme.textMuted}> - {props.message.type === "system" || props.message.type === "synthetic" ? props.message.text : ""} + {text()} </text> ) } +function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "skill" }> }) { + const { theme } = useTheme() + return ( + <InlineToolRow icon="→" color={theme.textMuted} pending="Skill" complete={true}> + Skill {props.message.name} + </InlineToolRow> + ) +} + function CompactionMessage() { const { theme } = useTheme() return <box border={["top"]} title=" Compaction " titleAlignment="center" borderColor={theme.borderActive} /> From a1250cd6901d9d7e8ee0129b82910446949a897a Mon Sep 17 00:00:00 2001 From: Dax Raad <d@ironbay.co> Date: Tue, 30 Jun 2026 01:16:11 -0400 Subject: [PATCH 23/27] test(sdk-next): update embedded client namespaces --- packages/sdk-next/test/embedded.test.ts | 52 ++++++++++++------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 5c1b8b238a..e2861c3a6d 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -26,54 +26,54 @@ test("embedded client uses the real router and handlers", async () => { }), }) - const created = yield* opencode.sessions.create({ + const created = yield* opencode.session.create({ id: sessionID, agent: Agent.ID.make("build"), location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), }) - yield* opencode.sessions.switchModel({ sessionID, model }) - const selected = yield* opencode.sessions.get({ sessionID }) - const page = yield* opencode.sessions.list({ directory: AbsolutePath.make(directory) }) - const active = yield* opencode.sessions.active() - const admitted = yield* opencode.sessions.prompt({ + yield* opencode.session.switchModel({ sessionID, model }) + const selected = yield* opencode.session.get({ sessionID }) + const page = yield* opencode.session.list({ directory: AbsolutePath.make(directory) }) + const active = yield* opencode.session.active() + const admitted = yield* opencode.session.prompt({ sessionID, prompt: Prompt.make({ text: "Do not run" }), resume: false, }) - const context = yield* opencode.sessions.context({ sessionID }) - const wake = yield* opencode.sessions.prompt({ + const context = yield* opencode.session.context({ sessionID }) + const wake = yield* opencode.session.prompt({ sessionID, prompt: Prompt.make({ text: "Promote this input" }), }) - const prompted = yield* opencode.sessions.events({ sessionID }).pipe( + const prompted = yield* opencode.session.events({ sessionID }).pipe( Stream.filter((event) => event.type === "session.next.prompted" && event.data.messageID === wake.id), Stream.runHead, Effect.timeout("10 seconds"), Effect.map(Option.getOrThrow), ) - const wakeContext = yield* opencode.sessions.context({ sessionID }) - const event = yield* opencode.sessions + const wakeContext = yield* opencode.session.context({ sessionID }) + const event = yield* opencode.session .events({ sessionID }) .pipe(Stream.take(1), Stream.runHead, Effect.map(Option.getOrUndefined)) const modelMessage = Option.fromNullishOr(context.find((message) => message.type === "model-switched")).pipe( Option.getOrThrow, ) - const message = yield* opencode.sessions.message({ sessionID, messageID: modelMessage.id }) - yield* opencode.sessions.interrupt({ sessionID }) - const other = yield* opencode.sessions.create({ + const message = yield* opencode.session.message({ sessionID, messageID: modelMessage.id }) + yield* opencode.session.interrupt({ sessionID }) + const other = yield* opencode.session.create({ location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), }) const missingSessionID = Session.ID.make(`ses_missing_${crypto.randomUUID()}`) const missing = yield* Effect.all( [ - opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip), - opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip), - opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip), + opencode.session.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip), + opencode.session.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip), + opencode.session.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip), ], { concurrency: "unbounded" }, ) const missingMessage = yield* Effect.flip( - opencode.sessions.message({ + opencode.session.message({ sessionID: other.id, messageID: modelMessage.id, }), @@ -116,7 +116,7 @@ test("Location-owned runner events reach the ready global client", async () => { const opencode = yield* OpenCode.create() const connected = yield* Latch.make(false) const prompted = yield* Deferred.make<OpenCodeEvent>() - yield* opencode.events.subscribe().pipe( + yield* opencode.event.subscribe().pipe( Stream.runForEach((event) => event.type === "server.connected" ? connected.open @@ -127,11 +127,11 @@ test("Location-owned runner events reach the ready global client", async () => { Effect.forkScoped, ) yield* connected.await - yield* opencode.sessions.create({ + yield* opencode.session.create({ id: sessionID, location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), }) - yield* opencode.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) }) + yield* opencode.session.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) }) const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds")) expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) })) @@ -167,14 +167,14 @@ test("independent embedded hosts do not share live notifications", async () => { : Effect.void, ) - yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped) - yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped) + yield* first.event.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped) + yield* second.event.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped) yield* Effect.all([firstReady.await, secondReady.await], { discard: true }) - yield* first.sessions.create({ + yield* first.session.create({ id: sessionID, location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), }) - yield* first.sessions.switchAgent({ sessionID, agent: Agent.ID.make("plan") }) + yield* first.session.switchAgent({ sessionID, agent: Agent.ID.make("plan") }) yield* firstEvent.await.pipe(Effect.timeout("2 seconds")) expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true) @@ -197,7 +197,7 @@ test("embedded client is available as a Layer service", async () => { const created = await Effect.runPromise( Effect.gen(function* () { const opencode = yield* OpenCode.Service - return yield* opencode.sessions.create({ + return yield* opencode.session.create({ id: sessionID, location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), }) From 75715e21155ae233af3d1f5e890ebda83f280d9b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 30 Jun 2026 07:54:18 -0500 Subject: [PATCH 24/27] fix(core): parse models.dev reasoning options (#34618) --- packages/core/src/models-dev.ts | 16 ++++++++++++++++ packages/core/test/models.test.ts | 24 +++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index c41b82b5f4..3e3cdbe57a 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -44,6 +44,21 @@ const Cost = Schema.Struct({ ), }) +const ReasoningOption = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("effort"), + values: Schema.Array(Schema.String), + }), + Schema.Struct({ + type: Schema.Literal("toggle"), + }), + Schema.Struct({ + type: Schema.Literal("budget_tokens"), + min: Schema.optional(Schema.Finite), + max: Schema.optional(Schema.Finite), + }), +]) + export const Model = Schema.Struct({ id: Schema.String, name: Schema.String, @@ -51,6 +66,7 @@ export const Model = Schema.Struct({ release_date: Schema.String, attachment: Schema.Boolean, reasoning: Schema.Boolean, + reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), temperature: Schema.Boolean, tool_call: Schema.Boolean, interleaved: Schema.optional( diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 6419d7ba1b..30a71077da 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -1,5 +1,5 @@ import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" -import { Effect, Layer, Ref } from "effect" +import { Effect, Layer, Ref, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { FSUtil } from "@opencode-ai/core/fs-util" import { Flag } from "@opencode-ai/core/flag/flag" @@ -126,6 +126,28 @@ const initialState: MockState = { } describe("ModelsDev Service", () => { + it.effect("decodes known reasoning options", () => + Effect.sync(() => { + const result = Schema.decodeUnknownSync(ModelsDev.Model)({ + id: "reasoning-model", + name: "Reasoning Model", + release_date: "2026-01-01", + attachment: false, + reasoning: true, + reasoning_options: [ + { type: "effort", values: ["low", "high"] }, + { type: "budget_tokens", min: 1024, max: 8192 }, + { type: "toggle" }, + ], + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }) + + expect(result.reasoning_options?.map((item) => item.type)).toEqual(["effort", "budget_tokens", "toggle"]) + }), + ) + it.live("get() returns providers from disk when cache file exists", () => Effect.gen(function* () { yield* writeCache(fixture) From 12887e572e01c81472e117dbf3078978067f2441 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:24:54 -0500 Subject: [PATCH 25/27] fix(core): align agent tests with universal default permissions (#34561) --- packages/core/src/config/plugin/agent.ts | 1 - packages/core/test/config/agent.test.ts | 18 ++++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 1dbff023f4..48efe75804 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -60,7 +60,6 @@ export const Plugin = define({ const configuredDefault = Config.latest(documents, "default_agent") if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault)) for (const current of draft.list()) { - yield* Effect.log({ msg: "applying permissions", id: current.id, permissions: global }) draft.update(current.id, (agent) => agent.permissions.push(...global)) } diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 7085f65cd7..855659a019 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -77,6 +77,8 @@ describe("ConfigAgentPlugin.Plugin", () => { const buildAgent = yield* agents.get(build) if (!buildAgent) throw new Error("expected configured build agent") expect(buildAgent.permissions).toEqual([ + { action: "*", resource: "*", effect: "allow" }, + { action: "external_directory", resource: "*", effect: "ask" }, { action: "bash", resource: "*", effect: "allow" }, { action: "bash", resource: "*", effect: "ask" }, { action: "read", resource: "*", effect: "allow" }, @@ -94,6 +96,8 @@ describe("ConfigAgentPlugin.Plugin", () => { model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" }, }) expect(reviewer.permissions).toEqual([ + { action: "*", resource: "*", effect: "allow" }, + { action: "external_directory", resource: "*", effect: "ask" }, { action: "bash", resource: "*", effect: "ask" }, { action: "read", resource: "*", effect: "allow" }, { action: "edit", resource: "*", effect: "deny" }, @@ -101,6 +105,8 @@ describe("ConfigAgentPlugin.Plugin", () => { ]) expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny") expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([ + { action: "*", resource: "*", effect: "allow" }, + { action: "external_directory", resource: "*", effect: "ask" }, { action: "bash", resource: "*", effect: "ask" }, { action: "read", resource: "*", effect: "allow" }, { action: "edit", resource: "*", effect: "allow" }, @@ -258,13 +264,21 @@ Use native v2 fields.`, system: "Review carefully.", description: "Markdown description", request: { body: { temperature: 0.5 } }, - permissions: [{ action: "edit", resource: "*", effect: "deny" }], + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + { action: "external_directory", resource: "*", effect: "ask" }, + { action: "edit", resource: "*", effect: "deny" }, + ], }) expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." }) expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({ system: "Use native v2 fields.", request: { headers: { "x-agent": "native" }, body: { effort: "high" } }, - permissions: [{ action: "edit", resource: "*", effect: "deny" }], + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + { action: "external_directory", resource: "*", effect: "ask" }, + { action: "edit", resource: "*", effect: "deny" }, + ], }) expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined() expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" }) From b1ca070b3bfdc2b5992abea633d7e54bf2a23a59 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:37:44 -0500 Subject: [PATCH 26/27] feat(core): add mcp support (#34513) --- bun.lock | 141 +++-- .../client/src/generated-effect/client.ts | 448 ++++++++-------- packages/client/src/generated/client.ts | 16 + packages/client/src/generated/types.ts | 24 + packages/core/package.json | 1 + packages/core/src/location-services.ts | 4 + packages/core/src/mcp/client.ts | 289 ++++++++++ packages/core/src/mcp/guidance.ts | 78 +++ packages/core/src/mcp/index.ts | 498 ++++++++++++++++++ packages/core/src/mcp/oauth.ts | 238 +++++++++ packages/core/src/session/runner/llm.ts | 5 +- packages/core/src/tool/mcp.ts | 101 ++++ packages/core/src/tool/tool.ts | 69 ++- packages/core/src/v1/config/migrate.ts | 14 +- packages/core/test/config/config.test.ts | 12 + .../core/test/session-runner-recorded.test.ts | 3 + packages/core/test/session-runner.test.ts | 3 + packages/protocol/src/api.ts | 2 + packages/protocol/src/groups/mcp.ts | 22 + packages/schema/src/mcp-event.ts | 11 +- packages/schema/src/mcp.ts | 39 ++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 31 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 108 ++++ packages/server/src/handlers.ts | 2 + packages/server/src/handlers/mcp.ts | 19 + packages/tui/src/app.tsx | 28 + packages/tui/src/component/dialog-mcp.tsx | 93 ++-- packages/tui/src/component/dialog-status.tsx | 29 +- packages/tui/src/context/data.tsx | 18 + packages/tui/src/routes/session/footer.tsx | 6 +- 30 files changed, 1965 insertions(+), 387 deletions(-) create mode 100644 packages/core/src/mcp/client.ts create mode 100644 packages/core/src/mcp/guidance.ts create mode 100644 packages/core/src/mcp/index.ts create mode 100644 packages/core/src/mcp/oauth.ts create mode 100644 packages/core/src/tool/mcp.ts create mode 100644 packages/protocol/src/groups/mcp.ts create mode 100644 packages/schema/src/mcp.ts create mode 100644 packages/server/src/handlers/mcp.ts diff --git a/bun.lock b/bun.lock index 48c6ce9af3..b652ae2416 100644 --- a/bun.lock +++ b/bun.lock @@ -304,6 +304,7 @@ "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-bun": "0.9.4", "@lydell/node-pty": "catalog:", + "@modelcontextprotocol/sdk": "1.29.0", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", @@ -3014,7 +3015,7 @@ "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], - "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -3184,7 +3185,7 @@ "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], - "body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="], @@ -3352,7 +3353,7 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -3362,7 +3363,7 @@ "cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="], - "cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], @@ -3670,7 +3671,7 @@ "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], - "express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], @@ -3728,7 +3729,7 @@ "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], - "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], @@ -3766,7 +3767,7 @@ "framer-motion": ["framer-motion@8.5.5", "", { "dependencies": { "@motionone/dom": "^10.15.3", "hey-listen": "^1.0.8", "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-5IDx5bxkjWHWUF3CVJoSyUVOtrbAxtzYBBowRE2uYI/6VYhkEBD+rbTHEGuUmbGHRj6YqqSfoG7Aa1cLyWCrBA=="], - "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -4330,11 +4331,11 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], - "merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], @@ -4820,7 +4821,7 @@ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], - "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], @@ -5006,7 +5007,7 @@ "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], - "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], @@ -5016,7 +5017,7 @@ "seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="], - "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], @@ -5332,7 +5333,7 @@ "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], @@ -5910,14 +5911,10 @@ "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], @@ -6050,8 +6047,12 @@ "@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + "@slack/bolt/express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], + "@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "@slack/bolt/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + "@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], "@slack/socket-mode/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="], @@ -6118,10 +6119,6 @@ "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], - "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], @@ -6176,10 +6173,6 @@ "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], - "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -6258,16 +6251,10 @@ "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], - "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -6390,8 +6377,6 @@ "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -6400,10 +6385,6 @@ "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -6448,7 +6429,7 @@ "tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="], - "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], @@ -6718,28 +6699,6 @@ "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - - "@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], - - "@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], @@ -6862,6 +6821,34 @@ "@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + "@slack/bolt/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], + + "@slack/bolt/express/body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="], + + "@slack/bolt/express/content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], + + "@slack/bolt/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "@slack/bolt/express/cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="], + + "@slack/bolt/express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + + "@slack/bolt/express/finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], + + "@slack/bolt/express/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], + + "@slack/bolt/express/merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], + + "@slack/bolt/express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], + + "@slack/bolt/express/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], + + "@slack/bolt/express/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], + + "@slack/bolt/express/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "@slack/bolt/raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], @@ -6892,8 +6879,6 @@ "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], - "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], @@ -6958,8 +6943,6 @@ "babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -7018,12 +7001,8 @@ "esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], - "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="], @@ -7060,8 +7039,6 @@ "rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -7076,8 +7053,6 @@ "tw-to-css/tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], - "type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -7266,10 +7241,6 @@ "@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - - "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], @@ -7302,6 +7273,20 @@ "@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "@slack/bolt/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "@slack/bolt/express/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], + + "@slack/bolt/express/body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "@slack/bolt/express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "@slack/bolt/express/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "@slack/bolt/express/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "@slack/bolt/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], @@ -7430,6 +7415,10 @@ "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@slack/bolt/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "@slack/bolt/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="], diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 465a245d1a..31df1aacc3 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -410,83 +410,90 @@ const adaptGroup8 = (raw: RawClient["server.integration"]) => ({ attemptCancel: Endpoint8_6(raw), }) -type Endpoint9_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0] -type Endpoint9_0Input = { - readonly credentialID: Endpoint9_0Request["params"]["credentialID"] - readonly location?: Endpoint9_0Request["query"]["location"] - readonly label: Endpoint9_0Request["payload"]["label"] +type Endpoint9_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0] +type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] } +const Endpoint9_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint9_0Input) => + raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup9 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint9_0(raw) }) + +type Endpoint10_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0] +type Endpoint10_0Input = { + readonly credentialID: Endpoint10_0Request["params"]["credentialID"] + readonly location?: Endpoint10_0Request["query"]["location"] + readonly label: Endpoint10_0Request["payload"]["label"] } -const Endpoint9_0 = (raw: RawClient["server.credential"]) => (input: Endpoint9_0Input) => +const Endpoint10_0 = (raw: RawClient["server.credential"]) => (input: Endpoint10_0Input) => raw["credential.update"]({ params: { credentialID: input["credentialID"] }, query: { location: input["location"] }, payload: { label: input["label"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint9_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0] -type Endpoint9_1Input = { - readonly credentialID: Endpoint9_1Request["params"]["credentialID"] - readonly location?: Endpoint9_1Request["query"]["location"] +type Endpoint10_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0] +type Endpoint10_1Input = { + readonly credentialID: Endpoint10_1Request["params"]["credentialID"] + readonly location?: Endpoint10_1Request["query"]["location"] } -const Endpoint9_1 = (raw: RawClient["server.credential"]) => (input: Endpoint9_1Input) => +const Endpoint10_1 = (raw: RawClient["server.credential"]) => (input: Endpoint10_1Input) => raw["credential.remove"]({ params: { credentialID: input["credentialID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup9 = (raw: RawClient["server.credential"]) => ({ update: Endpoint9_0(raw), remove: Endpoint9_1(raw) }) +const adaptGroup10 = (raw: RawClient["server.credential"]) => ({ update: Endpoint10_0(raw), remove: Endpoint10_1(raw) }) -type Endpoint10_0Request = Parameters<RawClient["server.project"]["project.current"]>[0] -type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] } -const Endpoint10_0 = (raw: RawClient["server.project"]) => (input?: Endpoint10_0Input) => +type Endpoint11_0Request = Parameters<RawClient["server.project"]["project.current"]>[0] +type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } +const Endpoint11_0 = (raw: RawClient["server.project"]) => (input?: Endpoint11_0Input) => raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint10_1Request = Parameters<RawClient["server.project"]["project.directories"]>[0] -type Endpoint10_1Input = { - readonly projectID: Endpoint10_1Request["params"]["projectID"] - readonly location?: Endpoint10_1Request["query"]["location"] +type Endpoint11_1Request = Parameters<RawClient["server.project"]["project.directories"]>[0] +type Endpoint11_1Input = { + readonly projectID: Endpoint11_1Request["params"]["projectID"] + readonly location?: Endpoint11_1Request["query"]["location"] } -const Endpoint10_1 = (raw: RawClient["server.project"]) => (input: Endpoint10_1Input) => +const Endpoint11_1 = (raw: RawClient["server.project"]) => (input: Endpoint11_1Input) => raw["project.directories"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup10 = (raw: RawClient["server.project"]) => ({ - current: Endpoint10_0(raw), - directories: Endpoint10_1(raw), +const adaptGroup11 = (raw: RawClient["server.project"]) => ({ + current: Endpoint11_0(raw), + directories: Endpoint11_1(raw), }) -type Endpoint11_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0] -type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] } -const Endpoint11_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint11_0Input) => +type Endpoint12_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0] +type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] } +const Endpoint12_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint12_0Input) => raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint11_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0] -type Endpoint11_1Input = { readonly projectID?: Endpoint11_1Request["query"]["projectID"] } -const Endpoint11_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint11_1Input) => +type Endpoint12_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0] +type Endpoint12_1Input = { readonly projectID?: Endpoint12_1Request["query"]["projectID"] } +const Endpoint12_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint12_1Input) => raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint11_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0] -type Endpoint11_2Input = { readonly id: Endpoint11_2Request["params"]["id"] } -const Endpoint11_2 = (raw: RawClient["server.permission"]) => (input: Endpoint11_2Input) => +type Endpoint12_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0] +type Endpoint12_2Input = { readonly id: Endpoint12_2Request["params"]["id"] } +const Endpoint12_2 = (raw: RawClient["server.permission"]) => (input: Endpoint12_2Input) => raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint11_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0] -type Endpoint11_3Input = { - readonly sessionID: Endpoint11_3Request["params"]["sessionID"] - readonly id?: Endpoint11_3Request["payload"]["id"] - readonly action: Endpoint11_3Request["payload"]["action"] - readonly resources: Endpoint11_3Request["payload"]["resources"] - readonly save?: Endpoint11_3Request["payload"]["save"] - readonly metadata?: Endpoint11_3Request["payload"]["metadata"] - readonly source?: Endpoint11_3Request["payload"]["source"] - readonly agent?: Endpoint11_3Request["payload"]["agent"] +type Endpoint12_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0] +type Endpoint12_3Input = { + readonly sessionID: Endpoint12_3Request["params"]["sessionID"] + readonly id?: Endpoint12_3Request["payload"]["id"] + readonly action: Endpoint12_3Request["payload"]["action"] + readonly resources: Endpoint12_3Request["payload"]["resources"] + readonly save?: Endpoint12_3Request["payload"]["save"] + readonly metadata?: Endpoint12_3Request["payload"]["metadata"] + readonly source?: Endpoint12_3Request["payload"]["source"] + readonly agent?: Endpoint12_3Request["payload"]["agent"] } -const Endpoint11_3 = (raw: RawClient["server.permission"]) => (input: Endpoint11_3Input) => +const Endpoint12_3 = (raw: RawClient["server.permission"]) => (input: Endpoint12_3Input) => raw["session.permission.create"]({ params: { sessionID: input["sessionID"] }, payload: { @@ -503,87 +510,87 @@ const Endpoint11_3 = (raw: RawClient["server.permission"]) => (input: Endpoint11 Effect.map((value) => value.data), ) -type Endpoint11_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0] -type Endpoint11_4Input = { readonly sessionID: Endpoint11_4Request["params"]["sessionID"] } -const Endpoint11_4 = (raw: RawClient["server.permission"]) => (input: Endpoint11_4Input) => +type Endpoint12_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0] +type Endpoint12_4Input = { readonly sessionID: Endpoint12_4Request["params"]["sessionID"] } +const Endpoint12_4 = (raw: RawClient["server.permission"]) => (input: Endpoint12_4Input) => raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint11_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0] -type Endpoint11_5Input = { - readonly sessionID: Endpoint11_5Request["params"]["sessionID"] - readonly requestID: Endpoint11_5Request["params"]["requestID"] +type Endpoint12_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0] +type Endpoint12_5Input = { + readonly sessionID: Endpoint12_5Request["params"]["sessionID"] + readonly requestID: Endpoint12_5Request["params"]["requestID"] } -const Endpoint11_5 = (raw: RawClient["server.permission"]) => (input: Endpoint11_5Input) => +const Endpoint12_5 = (raw: RawClient["server.permission"]) => (input: Endpoint12_5Input) => raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint11_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0] -type Endpoint11_6Input = { - readonly sessionID: Endpoint11_6Request["params"]["sessionID"] - readonly requestID: Endpoint11_6Request["params"]["requestID"] - readonly reply: Endpoint11_6Request["payload"]["reply"] - readonly message?: Endpoint11_6Request["payload"]["message"] +type Endpoint12_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0] +type Endpoint12_6Input = { + readonly sessionID: Endpoint12_6Request["params"]["sessionID"] + readonly requestID: Endpoint12_6Request["params"]["requestID"] + readonly reply: Endpoint12_6Request["payload"]["reply"] + readonly message?: Endpoint12_6Request["payload"]["message"] } -const Endpoint11_6 = (raw: RawClient["server.permission"]) => (input: Endpoint11_6Input) => +const Endpoint12_6 = (raw: RawClient["server.permission"]) => (input: Endpoint12_6Input) => raw["session.permission.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { reply: input["reply"], message: input["message"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup11 = (raw: RawClient["server.permission"]) => ({ - listRequests: Endpoint11_0(raw), - listSaved: Endpoint11_1(raw), - removeSaved: Endpoint11_2(raw), - create: Endpoint11_3(raw), - list: Endpoint11_4(raw), - get: Endpoint11_5(raw), - reply: Endpoint11_6(raw), +const adaptGroup12 = (raw: RawClient["server.permission"]) => ({ + listRequests: Endpoint12_0(raw), + listSaved: Endpoint12_1(raw), + removeSaved: Endpoint12_2(raw), + create: Endpoint12_3(raw), + list: Endpoint12_4(raw), + get: Endpoint12_5(raw), + reply: Endpoint12_6(raw), }) -type Endpoint12_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0] -type Endpoint12_0Input = { - readonly location?: Endpoint12_0Request["query"]["location"] - readonly path?: Endpoint12_0Request["query"]["path"] +type Endpoint13_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0] +type Endpoint13_0Input = { + readonly location?: Endpoint13_0Request["query"]["location"] + readonly path?: Endpoint13_0Request["query"]["path"] } -const Endpoint12_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint12_0Input) => +const Endpoint13_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint13_0Input) => raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint12_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0] -type Endpoint12_1Input = { - readonly location?: Endpoint12_1Request["query"]["location"] - readonly query: Endpoint12_1Request["query"]["query"] - readonly type?: Endpoint12_1Request["query"]["type"] - readonly limit?: Endpoint12_1Request["query"]["limit"] +type Endpoint13_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0] +type Endpoint13_1Input = { + readonly location?: Endpoint13_1Request["query"]["location"] + readonly query: Endpoint13_1Request["query"]["query"] + readonly type?: Endpoint13_1Request["query"]["type"] + readonly limit?: Endpoint13_1Request["query"]["limit"] } -const Endpoint12_1 = (raw: RawClient["server.fs"]) => (input: Endpoint12_1Input) => +const Endpoint13_1 = (raw: RawClient["server.fs"]) => (input: Endpoint13_1Input) => raw["fs.find"]({ query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup12 = (raw: RawClient["server.fs"]) => ({ list: Endpoint12_0(raw), find: Endpoint12_1(raw) }) +const adaptGroup13 = (raw: RawClient["server.fs"]) => ({ list: Endpoint13_0(raw), find: Endpoint13_1(raw) }) -type Endpoint13_0Request = Parameters<RawClient["server.command"]["command.list"]>[0] -type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] } -const Endpoint13_0 = (raw: RawClient["server.command"]) => (input?: Endpoint13_0Input) => +type Endpoint14_0Request = Parameters<RawClient["server.command"]["command.list"]>[0] +type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } +const Endpoint14_0 = (raw: RawClient["server.command"]) => (input?: Endpoint14_0Input) => raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup13 = (raw: RawClient["server.command"]) => ({ list: Endpoint13_0(raw) }) +const adaptGroup14 = (raw: RawClient["server.command"]) => ({ list: Endpoint14_0(raw) }) -type Endpoint14_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0] -type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] } -const Endpoint14_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint14_0Input) => +type Endpoint15_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0] +type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] } +const Endpoint15_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint15_0Input) => raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup14 = (raw: RawClient["server.skill"]) => ({ list: Endpoint14_0(raw) }) +const adaptGroup15 = (raw: RawClient["server.skill"]) => ({ list: Endpoint15_0(raw) }) -const Endpoint15_0 = (raw: RawClient["server.event"]) => () => +const Endpoint16_0 = (raw: RawClient["server.event"]) => () => Stream.unwrap( raw["event.subscribe"]({}).pipe( Effect.mapError(mapClientError), @@ -591,23 +598,23 @@ const Endpoint15_0 = (raw: RawClient["server.event"]) => () => ), ) -const adaptGroup15 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint15_0(raw) }) +const adaptGroup16 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint16_0(raw) }) -type Endpoint16_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0] -type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] } -const Endpoint16_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint16_0Input) => +type Endpoint17_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0] +type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } +const Endpoint17_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint17_0Input) => raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0] -type Endpoint16_1Input = { - readonly location?: Endpoint16_1Request["query"]["location"] - readonly command?: Endpoint16_1Request["payload"]["command"] - readonly args?: Endpoint16_1Request["payload"]["args"] - readonly cwd?: Endpoint16_1Request["payload"]["cwd"] - readonly title?: Endpoint16_1Request["payload"]["title"] - readonly env?: Endpoint16_1Request["payload"]["env"] +type Endpoint17_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0] +type Endpoint17_1Input = { + readonly location?: Endpoint17_1Request["query"]["location"] + readonly command?: Endpoint17_1Request["payload"]["command"] + readonly args?: Endpoint17_1Request["payload"]["args"] + readonly cwd?: Endpoint17_1Request["payload"]["cwd"] + readonly title?: Endpoint17_1Request["payload"]["title"] + readonly env?: Endpoint17_1Request["payload"]["env"] } -const Endpoint16_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint16_1Input) => +const Endpoint17_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint17_1Input) => raw["pty.create"]({ query: { location: input?.["location"] }, payload: { @@ -619,201 +626,201 @@ const Endpoint16_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint16_1Inpu }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0] -type Endpoint16_2Input = { - readonly ptyID: Endpoint16_2Request["params"]["ptyID"] - readonly location?: Endpoint16_2Request["query"]["location"] +type Endpoint17_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0] +type Endpoint17_2Input = { + readonly ptyID: Endpoint17_2Request["params"]["ptyID"] + readonly location?: Endpoint17_2Request["query"]["location"] } -const Endpoint16_2 = (raw: RawClient["server.pty"]) => (input: Endpoint16_2Input) => +const Endpoint17_2 = (raw: RawClient["server.pty"]) => (input: Endpoint17_2Input) => raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint16_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0] -type Endpoint16_3Input = { - readonly ptyID: Endpoint16_3Request["params"]["ptyID"] - readonly location?: Endpoint16_3Request["query"]["location"] - readonly title?: Endpoint16_3Request["payload"]["title"] - readonly size?: Endpoint16_3Request["payload"]["size"] +type Endpoint17_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0] +type Endpoint17_3Input = { + readonly ptyID: Endpoint17_3Request["params"]["ptyID"] + readonly location?: Endpoint17_3Request["query"]["location"] + readonly title?: Endpoint17_3Request["payload"]["title"] + readonly size?: Endpoint17_3Request["payload"]["size"] } -const Endpoint16_3 = (raw: RawClient["server.pty"]) => (input: Endpoint16_3Input) => +const Endpoint17_3 = (raw: RawClient["server.pty"]) => (input: Endpoint17_3Input) => raw["pty.update"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] }, payload: { title: input["title"], size: input["size"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint16_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0] -type Endpoint16_4Input = { - readonly ptyID: Endpoint16_4Request["params"]["ptyID"] - readonly location?: Endpoint16_4Request["query"]["location"] +type Endpoint17_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0] +type Endpoint17_4Input = { + readonly ptyID: Endpoint17_4Request["params"]["ptyID"] + readonly location?: Endpoint17_4Request["query"]["location"] } -const Endpoint16_4 = (raw: RawClient["server.pty"]) => (input: Endpoint16_4Input) => +const Endpoint17_4 = (raw: RawClient["server.pty"]) => (input: Endpoint17_4Input) => raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup16 = (raw: RawClient["server.pty"]) => ({ - list: Endpoint16_0(raw), - create: Endpoint16_1(raw), - get: Endpoint16_2(raw), - update: Endpoint16_3(raw), - remove: Endpoint16_4(raw), +const adaptGroup17 = (raw: RawClient["server.pty"]) => ({ + list: Endpoint17_0(raw), + create: Endpoint17_1(raw), + get: Endpoint17_2(raw), + update: Endpoint17_3(raw), + remove: Endpoint17_4(raw), }) -type Endpoint17_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0] -type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] } -const Endpoint17_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint17_0Input) => +type Endpoint18_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0] +type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] } +const Endpoint18_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint18_0Input) => raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint17_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0] -type Endpoint17_1Input = { - readonly location?: Endpoint17_1Request["query"]["location"] - readonly command: Endpoint17_1Request["payload"]["command"] - readonly cwd?: Endpoint17_1Request["payload"]["cwd"] - readonly timeout?: Endpoint17_1Request["payload"]["timeout"] - readonly metadata?: Endpoint17_1Request["payload"]["metadata"] +type Endpoint18_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0] +type Endpoint18_1Input = { + readonly location?: Endpoint18_1Request["query"]["location"] + readonly command: Endpoint18_1Request["payload"]["command"] + readonly cwd?: Endpoint18_1Request["payload"]["cwd"] + readonly timeout?: Endpoint18_1Request["payload"]["timeout"] + readonly metadata?: Endpoint18_1Request["payload"]["metadata"] } -const Endpoint17_1 = (raw: RawClient["server.shell"]) => (input: Endpoint17_1Input) => +const Endpoint18_1 = (raw: RawClient["server.shell"]) => (input: Endpoint18_1Input) => raw["shell.create"]({ query: { location: input["location"] }, payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint17_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0] -type Endpoint17_2Input = { - readonly id: Endpoint17_2Request["params"]["id"] - readonly location?: Endpoint17_2Request["query"]["location"] +type Endpoint18_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0] +type Endpoint18_2Input = { + readonly id: Endpoint18_2Request["params"]["id"] + readonly location?: Endpoint18_2Request["query"]["location"] } -const Endpoint17_2 = (raw: RawClient["server.shell"]) => (input: Endpoint17_2Input) => +const Endpoint18_2 = (raw: RawClient["server.shell"]) => (input: Endpoint18_2Input) => raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint17_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0] -type Endpoint17_3Input = { - readonly id: Endpoint17_3Request["params"]["id"] - readonly location?: Endpoint17_3Request["query"]["location"] - readonly cursor?: Endpoint17_3Request["query"]["cursor"] - readonly limit?: Endpoint17_3Request["query"]["limit"] +type Endpoint18_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0] +type Endpoint18_3Input = { + readonly id: Endpoint18_3Request["params"]["id"] + readonly location?: Endpoint18_3Request["query"]["location"] + readonly cursor?: Endpoint18_3Request["query"]["cursor"] + readonly limit?: Endpoint18_3Request["query"]["limit"] } -const Endpoint17_3 = (raw: RawClient["server.shell"]) => (input: Endpoint17_3Input) => +const Endpoint18_3 = (raw: RawClient["server.shell"]) => (input: Endpoint18_3Input) => raw["shell.output"]({ params: { id: input["id"] }, query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint17_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0] -type Endpoint17_4Input = { - readonly id: Endpoint17_4Request["params"]["id"] - readonly location?: Endpoint17_4Request["query"]["location"] +type Endpoint18_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0] +type Endpoint18_4Input = { + readonly id: Endpoint18_4Request["params"]["id"] + readonly location?: Endpoint18_4Request["query"]["location"] } -const Endpoint17_4 = (raw: RawClient["server.shell"]) => (input: Endpoint17_4Input) => +const Endpoint18_4 = (raw: RawClient["server.shell"]) => (input: Endpoint18_4Input) => raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup17 = (raw: RawClient["server.shell"]) => ({ - list: Endpoint17_0(raw), - create: Endpoint17_1(raw), - get: Endpoint17_2(raw), - output: Endpoint17_3(raw), - remove: Endpoint17_4(raw), +const adaptGroup18 = (raw: RawClient["server.shell"]) => ({ + list: Endpoint18_0(raw), + create: Endpoint18_1(raw), + get: Endpoint18_2(raw), + output: Endpoint18_3(raw), + remove: Endpoint18_4(raw), }) -type Endpoint18_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0] -type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] } -const Endpoint18_0 = (raw: RawClient["server.question"]) => (input?: Endpoint18_0Input) => +type Endpoint19_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0] +type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] } +const Endpoint19_0 = (raw: RawClient["server.question"]) => (input?: Endpoint19_0Input) => raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint18_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0] -type Endpoint18_1Input = { readonly sessionID: Endpoint18_1Request["params"]["sessionID"] } -const Endpoint18_1 = (raw: RawClient["server.question"]) => (input: Endpoint18_1Input) => +type Endpoint19_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0] +type Endpoint19_1Input = { readonly sessionID: Endpoint19_1Request["params"]["sessionID"] } +const Endpoint19_1 = (raw: RawClient["server.question"]) => (input: Endpoint19_1Input) => raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint18_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0] -type Endpoint18_2Input = { - readonly sessionID: Endpoint18_2Request["params"]["sessionID"] - readonly requestID: Endpoint18_2Request["params"]["requestID"] - readonly answers: Endpoint18_2Request["payload"]["answers"] +type Endpoint19_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0] +type Endpoint19_2Input = { + readonly sessionID: Endpoint19_2Request["params"]["sessionID"] + readonly requestID: Endpoint19_2Request["params"]["requestID"] + readonly answers: Endpoint19_2Request["payload"]["answers"] } -const Endpoint18_2 = (raw: RawClient["server.question"]) => (input: Endpoint18_2Input) => +const Endpoint19_2 = (raw: RawClient["server.question"]) => (input: Endpoint19_2Input) => raw["session.question.reply"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] }, payload: { answers: input["answers"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint18_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0] -type Endpoint18_3Input = { - readonly sessionID: Endpoint18_3Request["params"]["sessionID"] - readonly requestID: Endpoint18_3Request["params"]["requestID"] +type Endpoint19_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0] +type Endpoint19_3Input = { + readonly sessionID: Endpoint19_3Request["params"]["sessionID"] + readonly requestID: Endpoint19_3Request["params"]["requestID"] } -const Endpoint18_3 = (raw: RawClient["server.question"]) => (input: Endpoint18_3Input) => +const Endpoint19_3 = (raw: RawClient["server.question"]) => (input: Endpoint19_3Input) => raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe( Effect.mapError(mapClientError), ) -const adaptGroup18 = (raw: RawClient["server.question"]) => ({ - listRequests: Endpoint18_0(raw), - list: Endpoint18_1(raw), - reply: Endpoint18_2(raw), - reject: Endpoint18_3(raw), +const adaptGroup19 = (raw: RawClient["server.question"]) => ({ + listRequests: Endpoint19_0(raw), + list: Endpoint19_1(raw), + reply: Endpoint19_2(raw), + reject: Endpoint19_3(raw), }) -type Endpoint19_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0] -type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] } -const Endpoint19_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint19_0Input) => +type Endpoint20_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0] +type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] } +const Endpoint20_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint20_0Input) => raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup19 = (raw: RawClient["server.reference"]) => ({ list: Endpoint19_0(raw) }) +const adaptGroup20 = (raw: RawClient["server.reference"]) => ({ list: Endpoint20_0(raw) }) -type Endpoint20_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0] -type Endpoint20_0Input = { - readonly projectID: Endpoint20_0Request["params"]["projectID"] - readonly location?: Endpoint20_0Request["query"]["location"] - readonly strategy: Endpoint20_0Request["payload"]["strategy"] - readonly directory: Endpoint20_0Request["payload"]["directory"] - readonly name?: Endpoint20_0Request["payload"]["name"] +type Endpoint21_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0] +type Endpoint21_0Input = { + readonly projectID: Endpoint21_0Request["params"]["projectID"] + readonly location?: Endpoint21_0Request["query"]["location"] + readonly strategy: Endpoint21_0Request["payload"]["strategy"] + readonly directory: Endpoint21_0Request["payload"]["directory"] + readonly name?: Endpoint21_0Request["payload"]["name"] } -const Endpoint20_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_0Input) => +const Endpoint21_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_0Input) => raw["projectCopy.create"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint20_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0] -type Endpoint20_1Input = { - readonly projectID: Endpoint20_1Request["params"]["projectID"] - readonly location?: Endpoint20_1Request["query"]["location"] - readonly directory: Endpoint20_1Request["payload"]["directory"] - readonly force: Endpoint20_1Request["payload"]["force"] +type Endpoint21_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0] +type Endpoint21_1Input = { + readonly projectID: Endpoint21_1Request["params"]["projectID"] + readonly location?: Endpoint21_1Request["query"]["location"] + readonly directory: Endpoint21_1Request["payload"]["directory"] + readonly force: Endpoint21_1Request["payload"]["force"] } -const Endpoint20_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_1Input) => +const Endpoint21_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_1Input) => raw["projectCopy.remove"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, payload: { directory: input["directory"], force: input["force"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint20_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0] -type Endpoint20_2Input = { - readonly projectID: Endpoint20_2Request["params"]["projectID"] - readonly location?: Endpoint20_2Request["query"]["location"] +type Endpoint21_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0] +type Endpoint21_2Input = { + readonly projectID: Endpoint21_2Request["params"]["projectID"] + readonly location?: Endpoint21_2Request["query"]["location"] } -const Endpoint20_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_2Input) => +const Endpoint21_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_2Input) => raw["projectCopy.refresh"]({ params: { projectID: input["projectID"] }, query: { location: input["location"] }, }).pipe(Effect.mapError(mapClientError)) -const adaptGroup20 = (raw: RawClient["server.projectCopy"]) => ({ - create: Endpoint20_0(raw), - remove: Endpoint20_1(raw), - refresh: Endpoint20_2(raw), +const adaptGroup21 = (raw: RawClient["server.projectCopy"]) => ({ + create: Endpoint21_0(raw), + remove: Endpoint21_1(raw), + refresh: Endpoint21_2(raw), }) const adaptClient = (raw: RawClient) => ({ @@ -826,18 +833,19 @@ const adaptClient = (raw: RawClient) => ({ generate: adaptGroup6(raw["server.generate"]), provider: adaptGroup7(raw["server.provider"]), integration: adaptGroup8(raw["server.integration"]), - credential: adaptGroup9(raw["server.credential"]), - project: adaptGroup10(raw["server.project"]), - permission: adaptGroup11(raw["server.permission"]), - file: adaptGroup12(raw["server.fs"]), - command: adaptGroup13(raw["server.command"]), - skill: adaptGroup14(raw["server.skill"]), - event: adaptGroup15(raw["server.event"]), - pty: adaptGroup16(raw["server.pty"]), - shell: adaptGroup17(raw["server.shell"]), - question: adaptGroup18(raw["server.question"]), - reference: adaptGroup19(raw["server.reference"]), - projectCopy: adaptGroup20(raw["server.projectCopy"]), + "server.mcp": adaptGroup9(raw["server.mcp"]), + credential: adaptGroup10(raw["server.credential"]), + project: adaptGroup11(raw["server.project"]), + permission: adaptGroup12(raw["server.permission"]), + file: adaptGroup13(raw["server.fs"]), + command: adaptGroup14(raw["server.command"]), + skill: adaptGroup15(raw["server.skill"]), + event: adaptGroup16(raw["server.event"]), + pty: adaptGroup17(raw["server.pty"]), + shell: adaptGroup18(raw["server.shell"]), + question: adaptGroup19(raw["server.question"]), + reference: adaptGroup20(raw["server.reference"]), + projectCopy: adaptGroup21(raw["server.projectCopy"]), }) export const make = (options?: { readonly baseUrl?: URL | string }) => diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 7ea6525fae..a04597f6b1 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -67,6 +67,8 @@ import type { IntegrationAttemptCompleteOutput, IntegrationAttemptCancelInput, IntegrationAttemptCancelOutput, + ServerMcpListInput, + ServerMcpListOutput, CredentialUpdateInput, CredentialUpdateOutput, CredentialRemoveInput, @@ -713,6 +715,20 @@ export function make(options: ClientOptions) { requestOptions, ), }, + "server.mcp": { + list: (input?: ServerMcpListInput, requestOptions?: RequestOptions) => + request<ServerMcpListOutput>( + { + method: "GET", + path: `/api/mcp`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [401, 400], + empty: false, + }, + requestOptions, + ), + }, credential: { update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) => request<CredentialUpdateOutput>( diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 79234985d7..72796c3c34 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2466,6 +2466,30 @@ export type IntegrationAttemptCancelInput = { export type IntegrationAttemptCancelOutput = void +export type ServerMcpListInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ServerMcpListOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: ReadonlyArray<{ + readonly name: string + readonly status: + | { readonly status: "connected" } + | { readonly status: "disconnected" } + | { readonly status: "disabled" } + | { readonly status: "failed"; readonly error: string } + | { readonly status: "needs_auth" } + | { readonly status: "needs_client_registration"; readonly error: string } + }> +} + export type CredentialUpdateInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] readonly location?: { diff --git a/packages/core/package.json b/packages/core/package.json index edd9827c48..be7fcc7006 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -86,6 +86,7 @@ "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@lydell/node-pty": "catalog:", + "@modelcontextprotocol/sdk": "1.29.0", "@ff-labs/fff-bun": "0.9.4", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index cdc10ff3c8..81654eb3a4 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -16,6 +16,7 @@ import { Integration } from "./integration" import { Location } from "./location" import { LocationMutation } from "./location-mutation" import { LocationServiceMap } from "./location-service-map" +import { MCP } from "./mcp/index" import { PermissionV2 } from "./permission" import { PluginV2 } from "./plugin" import { PluginInternal } from "./plugin/internal" @@ -37,6 +38,7 @@ import { Snapshot } from "./snapshot" import { SystemContextBuiltIns } from "./system-context/builtins" import { SystemContextRegistry } from "./system-context/registry" import { BuiltInTools } from "./tool/builtins" +import { McpTool } from "./tool/mcp" import { ReadToolFileSystem } from "./tool/read-filesystem" import { ToolRegistry } from "./tool/registry" import { ToolOutputStore } from "./tool-output-store" @@ -68,6 +70,7 @@ export const locationServices = LayerNode.group([ SystemContextBuiltIns.node, LocationMutation.node, FileMutation.node, + MCP.node, PermissionV2.node, ToolOutputStore.node, ToolRegistry.node, @@ -80,6 +83,7 @@ export const locationServices = LayerNode.group([ Generate.node, ReadToolFileSystem.node, BuiltInTools.node, + McpTool.node, SessionRunnerModel.node, SessionCompaction.node, Snapshot.node, diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts new file mode 100644 index 0000000000..4a9b17f6b1 --- /dev/null +++ b/packages/core/src/mcp/client.ts @@ -0,0 +1,289 @@ +export * as MCPClient from "./client" + +import path from "node:path" +import { execFile } from "node:child_process" +import { pathToFileURL } from "node:url" +import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" +import { + CallToolResultSchema, + ListRootsRequestSchema, + ListToolsResultSchema, + type LoggingMessageNotification, + LoggingMessageNotificationSchema, + ToolListChangedNotificationSchema, + ToolSchema, +} from "@modelcontextprotocol/sdk/types.js" +import { Cause, Effect, Exit, Schema } from "effect" +import { ConfigMCP } from "../config/mcp" +import { InstallationVersion } from "../installation/version" + +const DEFAULT_STARTUP_TIMEOUT = 30_000 +const DEFAULT_REQUEST_TIMEOUT = 30_000 + +type Transport = StdioClientTransport | StreamableHTTPClientTransport + +// Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops +// only that field so a single bad schema doesn't blank out the whole tool list. +const TolerantListToolsResult = ListToolsResultSchema.extend({ + tools: ToolSchema.omit({ outputSchema: true }).array(), +}) + +export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", { + server: Schema.String, +}) {} + +export class ConnectError extends Schema.TaggedErrorClass<ConnectError>()("MCP.ConnectError", { + server: Schema.String, + message: Schema.String, +}) {} + +export interface ToolDefinition { + readonly name: string + readonly description: string | undefined + readonly inputSchema: unknown +} + +export type CallToolContent = + | { readonly type: "text"; readonly text: string } + | { readonly type: "media"; readonly data: string; readonly mimeType: string } + +export interface CallToolResult { + readonly isError: boolean + readonly structured: unknown + readonly content: ReadonlyArray<CallToolContent> +} + +export interface LogMessage { + readonly level: LoggingMessageNotification["params"]["level"] + readonly logger?: LoggingMessageNotification["params"]["logger"] + readonly data: LoggingMessageNotification["params"]["data"] +} + +/** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of core. */ +export interface Connection { + /** Server-supplied usage instructions from the initialize result, if any. */ + readonly instructions: string | undefined + /** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */ + readonly tools: () => Effect.Effect<ToolDefinition[], Error> + /** Invokes a tool on the server. Interruption aborts the in-flight request. */ + readonly callTool: (input: { + readonly name: string + readonly args?: Record<string, unknown> + }) => Effect.Effect<CallToolResult, Error> + readonly onClose: (callback: () => void) => void + /** Registers a callback fired when the server emits an MCP logging notification. */ + readonly onLog: (callback: (message: LogMessage) => void) => void + /** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */ + readonly onToolsChanged: (callback: () => void) => void +} + +/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */ +export const connect = Effect.fnUntraced(function* ( + server: string, + config: typeof ConfigMCP.Server.Type, + directory: string, + // Only consumed by the remote transport; stdio servers have no auth concept. A provider with no + // stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth. + authProvider?: OAuthClientProvider, +) { + const transport: Transport = yield* Effect.gen(function* () { + if (config.type === "local") { + const [command, ...args] = config.command + return new StdioClientTransport({ + command, + args, + cwd: config.cwd ? path.resolve(directory, config.cwd) : directory, + stderr: "pipe", + env: { + ...(process.env as Record<string, string>), + ...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}), + ...config.environment, + }, + }) + } + if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` }) + return new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: config.headers ? { headers: config.headers } : undefined, + authProvider, + }) + }) + const client = new Client( + { name: "opencode", version: InstallationVersion }, + { + capabilities: { + // https://github.com/anomalyco/opencode/issues/2308 + roots: {}, + }, + }, + ) + client.setRequestHandler(ListRootsRequestSchema, () => + Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }), + ) + + const exit = yield* Effect.tryPromise({ + try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }), + catch: (error) => error, + }).pipe(Effect.exit) + if (Exit.isSuccess(exit)) { + yield* Effect.addFinalizer(() => + cleanupStdioDescendants(transport).pipe( + Effect.andThen(Effect.promise(() => client.close())), + Effect.ignore, + ), + ) + const requestTimeout = config.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT + return { + instructions: client.getInstructions()?.trim() || undefined, + tools: () => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.tools) return [] + const tools = yield* Effect.tryPromise({ + try: () => + paginate( + async (cursor) => { + const params = cursor === undefined ? undefined : { cursor } + try { + return await client.listTools(params, { timeout: requestTimeout }) + } catch (error) { + if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error + return client.request({ method: "tools/list", params }, TolerantListToolsResult, { + timeout: requestTimeout, + }) + } + }, + (result) => result.tools, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => Effect.logWarning("failed to list MCP tools", { server, error: error.message })), + ) + return tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })) + }), + callTool: (input) => + Effect.tryPromise({ + try: (signal) => + client.callTool( + { name: input.name, arguments: input.args ?? {} }, + CallToolResultSchema, + // The SDK only sends a progress token when onprogress is present, which enables timeout resets. + { signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: () => {} }, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.map((result) => ({ + isError: result.isError === true, + structured: result.structuredContent, + content: result.content.flatMap((part): CallToolContent[] => { + if (part.type === "text") return [{ type: "text", text: part.text }] + if (part.type === "image" || part.type === "audio") + return [{ type: "media", data: part.data, mimeType: part.mimeType }] + if (part.type === "resource_link") return [{ type: "text", text: part.uri }] + if (part.type === "resource") { + const resource = part.resource + if ("text" in resource && typeof resource.text === "string") + return [{ type: "text", text: resource.text }] + if ("blob" in resource && typeof resource.blob === "string" && typeof resource.mimeType === "string") + return [{ type: "media", data: resource.blob, mimeType: resource.mimeType }] + return [{ type: "text", text: resource.uri }] + } + return [] + }), + })), + ), + onClose: (callback) => { + client.onclose = callback + }, + onLog: (callback) => { + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params)) + }, + onToolsChanged: (callback) => { + if (!client.getServerCapabilities()?.tools?.listChanged) return + client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback()) + }, + } satisfies Connection + } + + yield* cleanupStdioDescendants(transport).pipe( + Effect.andThen(Effect.promise(() => transport.close())), + Effect.ignore, + ) + const error = Cause.squash(exit.cause) + if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server }) + return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) }) +}) + +// SDK close stops the MCP process, but not child processes it spawned. +const cleanupStdioDescendants = (transport: Transport) => + Effect.gen(function* () { + if (!(transport instanceof StdioClientTransport)) return + const pid = transport.pid + if (typeof pid !== "number") return + yield* Effect.forEach( + yield* descendantPids(pid), + (pid) => + Effect.try({ + try: () => process.kill(pid, "SIGTERM"), + catch: () => undefined, + }).pipe(Effect.ignore), + { discard: true }, + ) + }) + +const descendantPids = Effect.fnUntraced(function* (root: number) { + if (process.platform === "win32") return [] + const result: number[] = [] + const queue = [root] + for (let index = 0; index < queue.length; index++) { + const parent = queue[index] + if (parent === undefined) return result + const children = (yield* childPids(parent)).filter((pid) => !result.includes(pid)) + result.push(...children) + queue.push(...children) + } + return result +}) + +const childPids = (pid: number) => + Effect.promise( + () => + new Promise<number[]>((resolve) => { + execFile("pgrep", ["-P", String(pid)], { encoding: "utf8" }, (_error, stdout) => { + resolve( + stdout + .split("\n") + .map((line) => Number.parseInt(line, 10)) + .filter((pid) => Number.isInteger(pid)), + ) + }) + }), + ) + +async function paginate<R extends { nextCursor?: string }, T>( + list: (cursor: string | undefined) => Promise<R>, + items: (result: R) => T[], +) { + const collected: T[] = [] + const seen = new Set<string>() + let cursor: string | undefined + while (true) { + const result = await list(cursor) + collected.push(...items(result)) + if (result.nextCursor === undefined) return collected + // A repeating cursor never terminates; bail instead of hanging the connection forever. + if (seen.has(result.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${result.nextCursor}`) + seen.add(result.nextCursor) + cursor = result.nextCursor + } +} + +const isOutputSchemaError = (error: Error) => + /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( + error.message, + ) diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts new file mode 100644 index 0000000000..7aa73f63df --- /dev/null +++ b/packages/core/src/mcp/guidance.ts @@ -0,0 +1,78 @@ +export * as McpGuidance from "./guidance" + +import { makeLocationNode } from "../effect/app-node" +import { Context, Effect, Layer, Schema } from "effect" +import { AgentV2 } from "../agent" +import { PermissionV2 } from "../permission" +import { McpTool } from "../tool/mcp" +import { MCP } from "./index" +import { SystemContext } from "../system-context/index" + +const Summary = Schema.Struct({ + server: Schema.String, + instructions: Schema.String, +}) +type Summary = typeof Summary.Type + +const render = (servers: ReadonlyArray<Summary>) => + [ + "<mcp_instructions>", + ...servers.flatMap((server) => [ + ` <server name="${server.server}">`, + ...server.instructions.split("\n").map((line) => ` ${line}`), + " </server>", + ]), + "</mcp_instructions>", + ].join("\n") + +export interface Interface { + readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext> +} + +export class Service extends Context.Service<Service, Interface>()("@opencode/v2/McpGuidance") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const mcp = yield* MCP.Service + + return Service.of({ + load: Effect.fn("McpGuidance.load")(function* (selection) { + const agent = selection.info + if (!agent) return SystemContext.empty + const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], { + concurrency: "unbounded", + }) + // Hide a server only when every tool it contributes is wholly denied for this agent. + const visible = instructions + .filter((item) => { + const owned = tools.filter((tool) => tool.server === item.server) + return ( + owned.length === 0 || + owned.some( + (tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", + ) + ) + }) + .map((item) => ({ server: item.server, instructions: item.instructions })) + if (visible.length === 0) return SystemContext.empty + return SystemContext.make({ + key: SystemContext.Key.make("core/mcp-guidance"), + codec: Schema.toCodecJson(Schema.Array(Summary)), + load: Effect.succeed(visible), + baseline: render, + update: (_previous, current) => + [ + "The available MCP server instructions have changed. This list supersedes the previous one.", + render(current), + ].join("\n"), + removed: () => "MCP server instructions are no longer available.", + }) + }), + }) + }), +) + +export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] }) diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts new file mode 100644 index 0000000000..3a422470fe --- /dev/null +++ b/packages/core/src/mcp/index.ts @@ -0,0 +1,498 @@ +export * as MCP from "./index" + +import { Mcp } from "@opencode-ai/schema/mcp" +import { McpEvent } from "@opencode-ai/schema/mcp-event" +import { createHash } from "node:crypto" +import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect" +import { makeLocationNode } from "../effect/app-node" +import { Config } from "../config" +import { ConfigMCP } from "../config/mcp" +import { Credential } from "../credential" +import { EventV2 } from "../event" +import { Integration } from "../integration" +import { IntegrationConnection } from "../integration/connection" +import { Location } from "../location" +import { MCPClient } from "./client" +import { MCPOAuth } from "./oauth" + +export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName")) +export type ServerName = typeof ServerName.Type + +// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here. +export const Status = Mcp.Status +export type Status = Mcp.Status + +export class ServerInfo extends Schema.Class<ServerInfo>("MCP.ServerInfo")({ + name: ServerName, + status: Status, + integrationID: Integration.ID.pipe(Schema.optional), + connection: IntegrationConnection.Info.pipe(Schema.optional), +}) {} + +export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.ServerInstructions")({ + server: ServerName, + instructions: Schema.String, +}) {} + +export class Tool extends Schema.Class<Tool>("MCP.Tool")({ + server: ServerName, + name: Schema.String, + description: Schema.String.pipe(Schema.optional), + inputSchema: Schema.Unknown.pipe(Schema.optional), +}) {} + +export const ToolResultContent = Schema.Union([ + Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }), + Schema.Struct({ type: Schema.Literal("media"), data: Schema.String, mimeType: Schema.String }), +]).pipe(Schema.toTaggedUnion("type")) +export type ToolResultContent = typeof ToolResultContent.Type + +export class ToolResult extends Schema.Class<ToolResult>("MCP.ToolResult")({ + server: ServerName, + tool: Schema.String, + isError: Schema.Boolean, + structured: Schema.Unknown.pipe(Schema.optional), + content: Schema.Array(ToolResultContent), +}) {} + +export class PromptArgument extends Schema.Class<PromptArgument>("MCP.PromptArgument")({ + name: Schema.String, + description: Schema.String.pipe(Schema.optional), + required: Schema.Boolean.pipe(Schema.optional), +}) {} + +export class Prompt extends Schema.Class<Prompt>("MCP.Prompt")({ + server: ServerName, + name: Schema.String, + description: Schema.String.pipe(Schema.optional), + arguments: Schema.Array(PromptArgument).pipe(Schema.optional), +}) {} + +export class PromptMessage extends Schema.Class<PromptMessage>("MCP.PromptMessage")({ + role: Schema.String, + content: Schema.Unknown, +}) {} + +export class PromptResult extends Schema.Class<PromptResult>("MCP.PromptResult")({ + server: ServerName, + name: Schema.String, + messages: Schema.Array(PromptMessage), +}) {} + +export class Resource extends Schema.Class<Resource>("MCP.Resource")({ + server: ServerName, + name: Schema.String, + uri: Schema.String, + description: Schema.String.pipe(Schema.optional), + mimeType: Schema.String.pipe(Schema.optional), +}) {} + +export class ResourceTemplate extends Schema.Class<ResourceTemplate>("MCP.ResourceTemplate")({ + server: ServerName, + name: Schema.String, + uriTemplate: Schema.String, + description: Schema.String.pipe(Schema.optional), + mimeType: Schema.String.pipe(Schema.optional), +}) {} + +export class ResourceCatalog extends Schema.Class<ResourceCatalog>("MCP.ResourceCatalog")({ + resources: Schema.Array(Resource), + templates: Schema.Array(ResourceTemplate), +}) {} + +export const ResourceContentPart = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("text"), + uri: Schema.String, + text: Schema.String, + mimeType: Schema.String.pipe(Schema.optional), + }), + Schema.Struct({ + type: Schema.Literal("blob"), + uri: Schema.String, + blob: Schema.String, + mimeType: Schema.String.pipe(Schema.optional), + }), +]).pipe(Schema.toTaggedUnion("type")) +export type ResourceContentPart = typeof ResourceContentPart.Type + +export class ResourceContent extends Schema.Class<ResourceContent>("MCP.ResourceContent")({ + server: ServerName, + uri: Schema.String, + contents: Schema.Array(ResourceContentPart), +}) {} + +export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", { + server: ServerName, +}) {} + +export class ToolCallError extends Schema.TaggedErrorClass<ToolCallError>()("MCP.ToolCallError", { + server: ServerName, + tool: Schema.String, + message: Schema.String, +}) {} + +type ServerEntry = { + readonly config: typeof ConfigMCP.Server.Type + status: Status + readonly startup: Deferred.Deferred<void> + scope?: Scope.Closeable + client?: MCPClient.Connection + tools?: ReadonlyArray<Tool> + // Set when a remote server is registered as an OAuth integration; the credential lives in the global store. + integrationID?: Integration.ID +} + +export interface Interface { + readonly servers: () => Effect.Effect<ServerInfo[]> + readonly tools: () => Effect.Effect<Tool[]> + readonly callTool: (input: { + readonly server: ServerName | string + readonly name: string + readonly args?: Record<string, unknown> + }) => Effect.Effect<ToolResult, NotFoundError | ToolCallError> + readonly instructions: () => Effect.Effect<ServerInstructions[]> + readonly prompts: () => Effect.Effect<Prompt[]> + readonly prompt: (input: { + readonly server: ServerName | string + readonly name: string + readonly args?: Record<string, string> + }) => Effect.Effect<PromptResult | undefined, NotFoundError> + readonly resourceCatalog: () => Effect.Effect<ResourceCatalog> + readonly readResource: (input: { + readonly server: ServerName | string + readonly uri: string + }) => Effect.Effect<ResourceContent | undefined, NotFoundError> +} + +export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const location = yield* Location.Service + const events = yield* EventV2.Service + const integration = yield* Integration.Service + const credentials = yield* Credential.Service + const root = yield* Scope.make() + const fork = yield* FiberSet.makeRuntime<never, void, never>() + yield* Effect.addFinalizer((exit) => Scope.close(root, exit)) + + const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") + // Global MCP timeout defaults, later config files overriding earlier ones. + const timeout = Object.assign( + {}, + ...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])), + ) + // Later config files win for duplicate server names; per-server timeout overrides globals. + const runtime = new Map<ServerName, ServerEntry>() + for (const entry of documents) { + for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) { + runtime.set(ServerName.make(name), { + config: { ...server, timeout: { ...timeout, ...server.timeout } }, + status: { status: "disconnected" }, + startup: Deferred.makeUnsafe<void>(), + }) + } + } + + // Register every remote server as an OAuth integration so credentials live in the global store + // rather than in committed config. Servers that connect anonymously simply never use the method. + const registrations: Array<{ + readonly name: ServerName + readonly remote: typeof ConfigMCP.Remote.Type + readonly integrationID: Integration.ID + readonly methodID: Integration.MethodID + }> = [] + for (const [name, entry] of runtime) { + if (entry.config.type !== "remote" || entry.config.oauth === false) continue + const remote = entry.config + // Key identity on name + url, not url alone: two configs for the same url under different names are + // distinct logical servers that may hold different accounts, so they must not share a credential row. + const suffix = "mcp_" + createHash("sha1").update(name + "\u0000" + remote.url).digest("hex").slice(0, 16) + entry.integrationID = Integration.ID.make(suffix) + registrations.push({ name, remote, integrationID: entry.integrationID, methodID: Integration.MethodID.make(suffix) }) + } + if (registrations.length > 0) + yield* integration.transform((draft) => { + for (const reg of registrations) { + draft.update(reg.integrationID, (ref) => { + ref.name = reg.name + }) + draft.method.update({ + integrationID: reg.integrationID, + method: { id: reg.methodID, type: "oauth", label: reg.name }, + authorize: () => MCPOAuth.authorize({ name: reg.name, config: reg.remote, methodID: reg.methodID }), + }) + } + }) + + const requireServer = Effect.fnUntraced(function* (server: ServerName | string) { + const name = ServerName.make(server) + const entry = runtime.get(name) + if (!entry) return yield* new NotFoundError({ server: name }) + return { name, entry } + }) + + const info = (name: ServerName, entry: ServerEntry, connection: IntegrationConnection.Info | undefined) => + new ServerInfo({ + name, + status: entry.status, + integrationID: entry.integrationID, + connection, + }) + + // Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and + // refreshes stored tokens, persisting refreshes back to the same credential row. The provider never + // opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect. + const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) { + if (entry.config.type !== "remote" || !entry.integrationID) return undefined + const remote = entry.config + const oauth = remote.oauth || undefined + const base = { + redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback", + scope: oauth?.scope, + client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined, + // No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser. + onRedirect: () => {}, + } + const stored = yield* credentials.list(entry.integrationID) + const found = stored.find((credential) => credential.value.type === "oauth") + if (!found || found.value.type !== "oauth") + // No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which + // ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw + // a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are + // unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth(). + return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() }) + const credentialID = found.id + const methodID = found.value.methodID + let current: Credential.OAuth | undefined = found.value + return MCPOAuth.provider({ + ...base, + // Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw + // credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect. + invalidate: async (scope) => { + if (scope === "verifier" || scope === "discovery") return + current = undefined + await Effect.runPromise(credentials.remove(credentialID)) + }, + store: { + tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined), + saveTokens: async (tokens) => { + current = MCPOAuth.toCredential({ + methodID, + serverUrl: remote.url, + tokens, + client: current ? MCPOAuth.clientFromCredential(current) : undefined, + }) + await Effect.runPromise(credentials.update(credentialID, { value: current })) + }, + clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined), + saveClientInformation: async () => {}, + codeVerifier: async () => undefined, + saveCodeVerifier: async () => {}, + }, + }) + }) + + const toTool = (server: ServerName, def: MCPClient.ToolDefinition) => + new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema }) + + const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => + connection.tools().pipe( + Effect.map((defs) => { + entry.tools = defs.map((def) => toTool(name, def)) + }), + ) + + const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => { + connection.onClose(() => { + entry.client = undefined + entry.tools = undefined + entry.status = { status: "failed", error: "Connection closed" } + fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) + fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) + }) + connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore))) + connection.onToolsChanged(() => { + fork( + refreshTools(name, entry, connection).pipe( + Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })), + Effect.ignore, + ), + ) + }) + } + + const serverLog = (server: ServerName, message: MCPClient.LogMessage) => { + const fields = { server, logger: message.logger, level: message.level, data: message.data } + switch (message.level) { + case "debug": + return Effect.logDebug("MCP server log", fields) + case "info": + case "notice": + return Effect.logInfo("MCP server log", fields) + case "warning": + return Effect.logWarning("MCP server log", fields) + case "error": + case "critical": + case "alert": + case "emergency": + return Effect.logError("MCP server log", fields) + } + } + + const startServer = (name: ServerName, entry: ServerEntry) => + Effect.gen(function* () { + const scope = yield* Scope.fork(root) + entry.scope = scope + const authProvider = yield* connectProvider(entry) + // List tools as part of connect so a failure here marks the server failed rather than + // leaving it connected with a silently empty tool list and no path to recover. + const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe( + Effect.flatMap((connection) => connection.tools().pipe(Effect.map((defs) => ({ connection, defs })))), + Scope.provide(scope), + Effect.exit, + ) + if (Exit.isSuccess(result)) { + entry.client = result.value.connection + entry.tools = result.value.defs.map((def) => toTool(name, def)) + entry.status = { status: "connected" } + watch(name, entry, result.value.connection) + yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length }) + // Announce the new tool set so the tool registry registers it. A server that finishes connecting + // after the initial registration sweep and emits no list-changed notification would otherwise + // stay invisible to the model. + yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) + yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + return + } + yield* Scope.close(scope, Exit.void) + entry.scope = undefined + const error = Cause.squash(result.cause) + entry.status = + error instanceof MCPClient.NeedsAuthError + ? { status: "needs_auth" } + : { status: "failed", error: error instanceof Error ? error.message : String(error) } + yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status }) + yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + }).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined))) + + // Disabled servers settle their startup immediately so queries never block on them. + for (const [name, entry] of runtime) { + if (entry.config.disabled) { + entry.status = { status: "disabled" } + Deferred.doneUnsafe(entry.startup, Exit.void) + continue + } + fork(startServer(name, entry)) + } + + // Bring a server online (or back to needs_auth) when its integration's credential changes, so an + // OAuth login takes effect without a restart. Only fires for the integrations we registered. + const owned = new Set(registrations.map((reg) => reg.integrationID)) + const reconnect = (integrationID: Integration.ID) => + Effect.gen(function* () { + const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID) + if (!match) return + const [name, entry] = match + if (entry.config.disabled) return + if (entry.scope) { + yield* Scope.close(entry.scope, Exit.void) + entry.scope = undefined + entry.client = undefined + entry.tools = undefined + } + yield* startServer(name, entry) + }) + fork( + events.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filter((event) => owned.has(event.data.integrationID)), + Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))), + Effect.ignore, + ), + ) + + const whenAllReady = Effect.forEach(runtime.values(), (entry) => Deferred.await(entry.startup), { + concurrency: "unbounded", + discard: true, + }) + const gate = Effect.fnUntraced(function* (server: ServerName | string) { + const target = yield* requireServer(server) + yield* Deferred.await(target.entry.startup) + }) + + return Service.of({ + servers: Effect.fn("MCP.servers")(function* () { + const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b)) + return yield* Effect.forEach(entries, ([name, entry]) => + Effect.gen(function* () { + const connection = entry.integrationID + ? yield* integration.connection.active(entry.integrationID) + : undefined + return info(name, entry, connection) + }), + ) + }), + tools: Effect.fn("MCP.tools")(function* () { + yield* whenAllReady + return Array.from(runtime.values()) + .flatMap((entry) => entry.tools ?? []) + .toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name)) + }), + callTool: Effect.fn("MCP.callTool")(function* (input) { + const target = yield* requireServer(input.server) + yield* Deferred.await(target.entry.startup) + if (!target.entry.client) + return yield* new ToolCallError({ + server: target.name, + tool: input.name, + message: "MCP server is not connected", + }) + const result = yield* target.entry.client + .callTool({ name: input.name, args: input.args }) + .pipe(Effect.mapError((error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }))) + return new ToolResult({ + server: target.name, + tool: input.name, + isError: result.isError, + structured: result.structured, + content: result.content, + }) + }), + instructions: Effect.fn("MCP.instructions")(function* () { + yield* whenAllReady + return Array.from(runtime) + .flatMap(([server, entry]) => { + const instructions = entry.client?.instructions + if (!instructions) return [] + return [new ServerInstructions({ server, instructions })] + }) + .toSorted((a, b) => a.server.localeCompare(b.server)) + }), + prompts: Effect.fn("MCP.prompts")(function* () { + yield* whenAllReady + return [] + }), + prompt: Effect.fn("MCP.prompt")(function* (input) { + yield* gate(input.server) + return undefined + }), + resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { + yield* whenAllReady + return new ResourceCatalog({ resources: [], templates: [] }) + }), + readResource: Effect.fn("MCP.readResource")(function* (input) { + yield* gate(input.server) + return undefined + }), + }) + }), +) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Config.node, Location.node, EventV2.node, Integration.node, Credential.node], +}) diff --git a/packages/core/src/mcp/oauth.ts b/packages/core/src/mcp/oauth.ts new file mode 100644 index 0000000000..fc4752c7af --- /dev/null +++ b/packages/core/src/mcp/oauth.ts @@ -0,0 +1,238 @@ +export * as MCPOAuth from "./oauth" + +import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" +import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js" +import { createServer } from "node:http" +import { Deferred, Effect } from "effect" +import { Credential } from "@opencode-ai/schema/credential" +import { ConfigMCP } from "../config/mcp" +import { OauthCallbackPage } from "../oauth/page" +import type { Integration } from "../integration" + +/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */ +export interface Store { + readonly tokens: () => Promise<OAuthTokens | undefined> + readonly saveTokens: (tokens: OAuthTokens) => Promise<void> + readonly clientInformation: () => Promise<OAuthClientInformationMixed | undefined> + readonly saveClientInformation: (info: OAuthClientInformationMixed) => Promise<void> + readonly codeVerifier: () => Promise<string | undefined> + readonly saveCodeVerifier: (verifier: string) => Promise<void> +} + +export interface Options { + /** Loopback URL the authorization server redirects back to after the user approves. */ + readonly redirectUrl: string + /** Space-delimited OAuth scopes to request when the server requires specific ones. */ + readonly scope?: string + /** CSRF state embedded in the authorization request; required by the spec and enforced by some servers. + * The caller is responsible for validating the value echoed back to the redirect. */ + readonly state?: string + /** Statically pre-registered client credentials from config; when set, the SDK skips dynamic registration. */ + readonly client?: { readonly id: string; readonly secret?: string } + /** Invoked by the SDK to drop credentials it has determined are invalid (e.g. a rejected refresh token). */ + readonly invalidate?: (scope: "all" | "client" | "tokens" | "verifier" | "discovery") => void | Promise<void> + /** Receives the authorization URL so the caller can open a browser and capture the eventual code. */ + readonly onRedirect: (url: URL) => void | Promise<void> + readonly store: Store +} + +/** + * Builds the MCP SDK's OAuthClientProvider. The SDK drives dynamic client registration, PKCE, and + * token refresh through these callbacks; we only persist whatever it hands back via `store`. + */ +export const provider = (options: Options): OAuthClientProvider => { + const state = options.state + const client = options.client + return { + redirectUrl: options.redirectUrl, + clientMetadata: { + redirect_uris: [options.redirectUrl], + client_name: "opencode", + client_uri: "https://opencode.ai", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: client?.secret ? "client_secret_post" : "none", + ...(options.scope ? { scope: options.scope } : {}), + }, + // Only advertise state when the caller supplied one (the interactive flow); the connect-time + // provider has no redirect to validate, so it omits it. + ...(state !== undefined ? { state: () => state } : {}), + // Static client config short-circuits dynamic registration; otherwise the SDK registers and we persist. + clientInformation: () => + client ? { client_id: client.id, client_secret: client.secret } : options.store.clientInformation(), + saveClientInformation: (info) => options.store.saveClientInformation(info), + tokens: () => options.store.tokens(), + saveTokens: (tokens) => options.store.saveTokens(tokens), + redirectToAuthorization: (url) => options.onRedirect(url), + ...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}), + saveCodeVerifier: (verifier) => options.store.saveCodeVerifier(verifier), + // The SDK only reads the verifier back after saving one earlier in the same flow; a miss means + // the flow was resumed without its session state, which the SDK surfaces as an auth failure. + codeVerifier: async () => { + const verifier = await options.store.codeVerifier() + if (!verifier) throw new Error("Missing PKCE code verifier for MCP OAuth flow") + return verifier + }, + } +} + +/** A Store that keeps OAuth artifacts in memory for the duration of one interactive login attempt. */ +export const memoryStore = (): Store => { + let tokens: OAuthTokens | undefined + let client: OAuthClientInformationMixed | undefined + let verifier: string | undefined + return { + tokens: async () => tokens, + saveTokens: async (value) => { + tokens = value + }, + clientInformation: async () => client, + saveClientInformation: async (value) => { + client = value + }, + codeVerifier: async () => verifier, + saveCodeVerifier: async (value) => { + verifier = value + }, + } +} + +/** Reads the dynamically-registered client info we stash in a credential's metadata, for token refresh. */ +export const clientFromCredential = (credential: Credential.OAuth) => + credential.metadata?.client as OAuthClientInformationMixed | undefined + +/** Folds SDK tokens (plus DCR client info and the server URL) into a storable credential. */ +export const toCredential = (input: { + readonly methodID: Integration.MethodID + readonly serverUrl: string + readonly tokens: OAuthTokens + readonly client: OAuthClientInformationMixed | undefined +}) => + Credential.OAuth.make({ + type: "oauth", + methodID: input.methodID, + access: input.tokens.access_token, + refresh: input.tokens.refresh_token ?? "", + // 0 marks an unknown/non-expiring token; toTokens then omits expires_in so the SDK won't force a refresh. + expires: input.tokens.expires_in ? Date.now() + input.tokens.expires_in * 1000 : 0, + metadata: { + serverUrl: input.serverUrl, + tokenType: input.tokens.token_type, + ...(input.tokens.scope ? { scope: input.tokens.scope } : {}), + ...(input.client ? { client: input.client } : {}), + }, + }) + +/** Reconstructs SDK tokens from a stored credential so the connect-time provider can present them. */ +export const toTokens = (credential: Credential.OAuth): OAuthTokens => { + const metadata = credential.metadata ?? {} + return { + access_token: credential.access, + token_type: typeof metadata.tokenType === "string" ? metadata.tokenType : "Bearer", + ...(credential.refresh ? { refresh_token: credential.refresh } : {}), + ...(credential.expires ? { expires_in: Math.max(0, Math.floor((credential.expires - Date.now()) / 1000)) } : {}), + ...(typeof metadata.scope === "string" ? { scope: metadata.scope } : {}), + } +} + +/** + * Runs the interactive OAuth login for one remote MCP server. Stands up a loopback callback server, + * lets the SDK drive DCR + PKCE to produce an authorization URL, and returns an attempt whose callback + * exchanges the redirect code for a storable credential. Scoped: the callback server closes with the scope. + */ +export const authorize = (input: { + readonly name: string + readonly config: typeof ConfigMCP.Remote.Type + readonly methodID: Integration.MethodID +}) => + Effect.gen(function* () { + const oauth = input.config.oauth || undefined + const store = memoryStore() + const code = yield* Deferred.make<string, Error>() + const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback" + const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url") + + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1") + if (url.pathname !== redirectPath) { + response.writeHead(404).end("Not found") + return + } + const fail = (reason: string) => { + Effect.runFork(Deferred.fail(code, new Error(reason))) + response.writeHead(400, { "Content-Type": "text/html" }).end(OauthCallbackPage.error(reason, { provider: input.name })) + } + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + if (error) return fail(error) + // Reject a redirect whose state does not match what we issued: this is the CSRF defense the + // state parameter exists for, so an attacker can't inject their own authorization code. + if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch") + const value = url.searchParams.get("code") + if (!value) return fail("Missing authorization code") + Effect.runFork(Deferred.succeed(code, value)) + response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name })) + }) + + // Bind the port the redirect will actually arrive on: an explicit callback_port wins, else the port + // pinned by redirect_uri, else an ephemeral port. Binding ephemerally while redirect_uri names a fixed + // port would send the browser somewhere nothing is listening, hanging the attempt until it expires. + const redirectPort = oauth?.redirect_uri ? Number(new URL(oauth.redirect_uri).port) || undefined : undefined + const port = yield* Effect.callback<number, Error>((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(oauth?.callback_port ?? redirectPort ?? 0, "127.0.0.1", () => { + const address = server.address() + resume( + address && typeof address === "object" + ? Effect.succeed(address.port) + : Effect.fail(new Error("Could not determine MCP OAuth callback port")), + ) + }) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + + let authorizationUrl: URL | undefined + const oauthProvider = provider({ + redirectUrl: oauth?.redirect_uri ?? `http://127.0.0.1:${port}${redirectPath}`, + scope: oauth?.scope, + state, + client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined, + onRedirect: (url) => { + authorizationUrl = url + }, + store, + }) + + const finalize = Effect.gen(function* () { + const tokens = yield* Effect.promise(() => store.tokens()) + if (!tokens) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not return OAuth tokens`)) + const client = yield* Effect.promise(() => store.clientInformation()) + return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client }) + }) + + const result = yield* Effect.tryPromise({ + try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) + + // The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step. + if (result === "AUTHORIZED") { + return { url: input.config.url, instructions: `Connected to ${input.name}.`, mode: "auto" as const, callback: finalize } + } + if (!authorizationUrl) + return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`)) + + return { + url: authorizationUrl.toString(), + instructions: `Authorize ${input.name} in your browser. This window will close automatically.`, + mode: "auto" as const, + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => + Effect.tryPromise({ + try: () => auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }), + ), + Effect.flatMap(() => finalize), + ), + } + }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 089991eed2..6d0e768dbc 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -20,6 +20,7 @@ import { SystemContext } from "../../system-context/index" import { SystemContextRegistry } from "../../system-context/registry" import { SkillGuidance } from "../../skill/guidance" import { ReferenceGuidance } from "../../reference/guidance" +import { McpGuidance } from "../../mcp/guidance" import { ToolRegistry } from "../../tool/registry" import { ToolOutputStore } from "../../tool-output-store" import { SessionContextEpoch } from "../context-epoch" @@ -102,6 +103,7 @@ export const layer = Layer.effect( const systemContext = yield* SystemContextRegistry.Service const skillGuidance = yield* SkillGuidance.Service const referenceGuidance = yield* ReferenceGuidance.Service + const mcpGuidance = yield* McpGuidance.Service const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service @@ -160,7 +162,7 @@ export const layer = Layer.effect( new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step }) const loadSystemContext = (agent: AgentV2.Selection) => - Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], { + Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load(), mcpGuidance.load(agent)], { concurrency: "unbounded", }).pipe(Effect.map(SystemContext.combine)) @@ -424,6 +426,7 @@ export const node = makeLocationNode({ SystemContextRegistry.node, SkillGuidance.node, ReferenceGuidance.node, + McpGuidance.node, SessionCompaction.node, Snapshot.node, Database.node, diff --git a/packages/core/src/tool/mcp.ts b/packages/core/src/tool/mcp.ts new file mode 100644 index 0000000000..571b747bab --- /dev/null +++ b/packages/core/src/tool/mcp.ts @@ -0,0 +1,101 @@ +export * as McpTool from "./mcp" + +import { createHash } from "node:crypto" +import { ToolFailure } from "@opencode-ai/llm" +import { McpEvent } from "@opencode-ai/schema/mcp-event" +import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect" +import { makeLocationNode } from "../effect/app-node" +import { EventV2 } from "../event" +import { MCP } from "../mcp" +import { Tool } from "./tool" +import { Tools } from "./tools" +import { ToolRegistry } from "./registry" + +const MAX_NAME_LENGTH = 64 +const HASH_LENGTH = 8 + +const sanitize = (value: string) => value.replace(/[^A-Za-z0-9_-]/g, "_") + +// Deterministic short suffix used to keep overlong or colliding names unique and stable across restarts. +const hashSuffix = (raw: string) => "_" + createHash("sha1").update(raw).digest("hex").slice(0, HASH_LENGTH) + +const fit = (base: string, raw: string) => base.slice(0, MAX_NAME_LENGTH - HASH_LENGTH - 1) + hashSuffix(raw) + +/** + * Registry/permission action name for an MCP tool: V1-compatible `<server>_<tool>` so existing deny + * rules keep working. Sanitized to a valid tool name, prefixed when it would not start with a letter, + * and hashed down when it would exceed the 64-char limit. + */ +export const name = (server: string, tool: string) => { + const joined = sanitize(server) + "_" + sanitize(tool) + const base = /^[A-Za-z]/.test(joined) ? joined : "mcp_" + joined + return base.length > MAX_NAME_LENGTH ? fit(base, `${server}\u0000${tool}`) : base +} + +const toContent = (part: MCP.ToolResultContent): Tool.Content => + part.type === "text" ? { type: "text", text: part.text } : { type: "file", data: part.data, mime: part.mimeType } + +const errorText = (content: ReadonlyArray<MCP.ToolResultContent>) => + content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join("\n") + .trim() + +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + const mcp = yield* MCP.Service + const tools = yield* Tools.Service + const events = yield* EventV2.Service + const scope = yield* Scope.Scope + const lock = Semaphore.makeUnsafe(1) + let current: Scope.Closeable | undefined + + const make = (server: MCP.ServerName, tool: MCP.Tool) => + Tool.make({ + description: tool.description ?? "", + jsonSchema: (tool.inputSchema as JsonSchema.JsonSchema | undefined) ?? { type: "object", properties: {} }, + execute: (input) => + Effect.gen(function* () { + const result = yield* mcp.callTool({ server, name: tool.name, args: (input ?? {}) as Record<string, unknown> }).pipe( + Effect.catchTags({ + "MCP.NotFoundError": (error) => new ToolFailure({ message: `MCP server "${error.server}" is not available` }), + "MCP.ToolCallError": (error) => new ToolFailure({ message: error.message }), + }), + ) + if (result.isError) + return yield* new ToolFailure({ message: errorText(result.content) || "MCP tool returned an error" }) + return { structured: result.structured ?? {}, content: result.content.map(toContent) } + }), + }) + + // Register the current tool set under a fresh child scope, then close the previous one so the + // registry never has a gap where MCP tools disappear mid-swap. + const reconcile = lock.withPermit( + Effect.gen(function* () { + const used = new Set<string>() + const record: Record<string, Tool.AnyTool> = {} + for (const tool of yield* mcp.tools()) { + const initial = name(tool.server, tool.name) + const key = used.has(initial) ? fit(initial, `${tool.server}\u0000${tool.name}`) : initial + used.add(key) + record[key] = make(tool.server, tool) + } + const next = yield* Scope.fork(scope) + yield* tools.register(record).pipe(Scope.provide(next), Effect.orDie) + if (current) yield* Scope.close(current, Exit.void) + current = next + }), + ) + + yield* reconcile.pipe(Effect.forkScoped) + yield* events + .subscribe(McpEvent.ToolsChanged) + .pipe(Stream.runForEach(() => reconcile), Effect.forkScoped({ startImmediately: true })) + }), +) + +export const node = makeLocationNode({ + name: "mcp-tools", + layer, + deps: [ToolRegistry.toolsNode, MCP.node, EventV2.node], +}) diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index 8ee5b76596..1968fc338b 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -60,6 +60,23 @@ type Config< }) => ReadonlyArray<Content> } +export type DynamicOutput = { + readonly structured: unknown + readonly content: ReadonlyArray<Content> +} + +/** + * Config for a tool whose input shape is a raw JSON Schema not known at compile + * time (MCP servers, plugin manifests). Input is passed through as `unknown`; + * `execute` returns the already-projected structured value and model content. + */ +type DynamicConfig = { + readonly description: string + readonly jsonSchema: JsonSchema.JsonSchema + readonly outputSchema?: JsonSchema.JsonSchema + readonly execute: (input: unknown, context: Context) => Effect.Effect<DynamicOutput, ToolFailure> +} + type Runtime = { readonly permission?: string readonly definition: (name: string) => ToolDefinition @@ -72,6 +89,17 @@ export function make< Input extends SchemaType<any>, Output extends SchemaType<any>, Structured extends SchemaType<any> = Output, +>(config: Config<Input, Output, Structured>): Definition<Input, Structured> +export function make(config: DynamicConfig): AnyTool +export function make(config: Config<any, any, any> | DynamicConfig): AnyTool { + if ("jsonSchema" in config) return makeDynamic(config) + return makeTyped(config) +} + +function makeTyped< + Input extends SchemaType<any>, + Output extends SchemaType<any>, + Structured extends SchemaType<any> = Output, >(config: Config<Input, Output, Structured>): Definition<Input, Structured> { const tool = Object.freeze({}) as Definition<Input, Structured> const definitions = new Map<string, ToolDefinition>() @@ -113,16 +141,8 @@ export function make< Effect.map(({ output, structured }) => ({ structured, content: - config.toModelOutput?.({ input, output }).map((part) => - part.type === "text" - ? { type: "text" as const, text: part.text } - : { - type: "file" as const, - uri: `data:${part.mime};base64,${part.data}`, - mime: part.mime, - name: part.name, - }, - ) ?? (typeof output === "string" ? [{ type: "text" as const, text: output }] : []), + config.toModelOutput?.({ input, output }).map(toModelContent) ?? + (typeof output === "string" ? [{ type: "text" as const, text: output }] : []), })), ), ), @@ -131,6 +151,35 @@ export function make< return tool } +function makeDynamic(config: DynamicConfig): AnyTool { + const tool = Object.freeze({}) as AnyTool + const definitions = new Map<string, ToolDefinition>() + runtimes.set(tool, { + definition: (name) => { + const cached = definitions.get(name) + if (cached) return cached + const definition = new ToolDefinition({ + name, + description: config.description, + inputSchema: config.jsonSchema, + outputSchema: config.outputSchema, + }) + definitions.set(name, definition) + return definition + }, + settle: (call, context) => + config + .execute(call.input, context) + .pipe(Effect.map((output) => ({ structured: output.structured, content: output.content.map(toModelContent) }))), + }) + return tool +} + +function toModelContent(part: Content) { + if (part.type === "text") return { type: "text" as const, text: part.text } + return { type: "file" as const, uri: `data:${part.mime};base64,${part.data}`, mime: part.mime, name: part.name } +} + export const validateName = (name: string) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name) ? Effect.void diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 5f62858e3c..2a9e1c7383 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -29,7 +29,19 @@ const keys = new Set([ export function isV1(input: unknown) { if (typeof input !== "object" || input === null || Array.isArray(input)) return false - return Object.keys(input).some((key) => keys.has(key)) + const record = input as Record<string, unknown> + if (Object.keys(record).some((key) => keys.has(key))) return true + // `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under + // `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`) + // counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers. + const mcp = record.mcp + return ( + typeof mcp === "object" && + mcp !== null && + !Array.isArray(mcp) && + !("servers" in mcp) && + Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server) + ) } export function migrate(info: typeof ConfigV1.Info.Type) { diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 0f61c6c1df..f16312b763 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -76,6 +76,18 @@ describe("Config", () => { }), ) + it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () => + Effect.sync(() => { + // V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates. + expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true) + expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true) + // V2 nests under `mcp.servers`, so it must not be misdetected and re-migrated. + expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false) + expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false) + expect(ConfigMigrateV1.isV1({ mcp: { timeout: { request: 1000 } } })).toBe(false) + }), + ) + it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () => Effect.sync(() => { FastCheck.assert( diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index fd819f9f7a..a6c86658b5 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -30,6 +30,7 @@ import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry import { SystemContext } from "@opencode-ai/core/system-context" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import { McpGuidance } from "@opencode-ai/core/mcp/guidance" import { describe, expect } from "bun:test" import { eq } from "drizzle-orm" import { Effect, Layer } from "effect" @@ -72,6 +73,7 @@ const systemContext = SystemContextRegistry.layer const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer)) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(SessionCompaction.layer), @@ -87,6 +89,7 @@ const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(agents), Layer.provide(skillGuidance), Layer.provide(referenceGuidance), + Layer.provide(mcpGuidance), Layer.provide(config), ) const execution = Layer.effect( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index aa9dfd63f6..2e17444321 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -52,6 +52,7 @@ import { SystemContext } from "@opencode-ai/core/system-context" import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" +import { McpGuidance } from "@opencode-ai/core/mcp/guidance" import { ModelV2 } from "@opencode-ai/core/model" import { Location } from "@opencode-ai/core/location" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -217,6 +218,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { ), }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const config = Layer.succeed( Config.Service, Config.Service.of({ @@ -248,6 +250,7 @@ const runner = SessionRunnerLLM.layer.pipe( Layer.provide(agents), Layer.provide(skillGuidance), Layer.provide(referenceGuidance), + Layer.provide(mcpGuidance), Layer.provide(config), ) const execution = Layer.effect( diff --git a/packages/protocol/src/api.ts b/packages/protocol/src/api.ts index e879c99d7a..03129d3618 100644 --- a/packages/protocol/src/api.ts +++ b/packages/protocol/src/api.ts @@ -21,6 +21,7 @@ import { ReferenceGroup } from "./groups/reference" import { Authorization } from "./middleware/authorization" import { LocationGroup } from "./groups/location" import { IntegrationGroup } from "./groups/integration" +import { McpGroup } from "./groups/mcp" import { CredentialGroup } from "./groups/credential" import { ProjectGroup } from "./groups/project" import { ProjectCopyGroup } from "./groups/project-copy" @@ -47,6 +48,7 @@ const makeApiFromGroup = < .add(GenerateGroup.middleware(locationMiddleware)) .add(ProviderGroup.middleware(locationMiddleware)) .add(IntegrationGroup.middleware(locationMiddleware)) + .add(McpGroup.middleware(locationMiddleware)) .add(CredentialGroup.middleware(locationMiddleware)) .add(ProjectGroup.middleware(locationMiddleware)) .add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware)) diff --git a/packages/protocol/src/groups/mcp.ts b/packages/protocol/src/groups/mcp.ts new file mode 100644 index 0000000000..450a2a67c4 --- /dev/null +++ b/packages/protocol/src/groups/mcp.ts @@ -0,0 +1,22 @@ +import { Mcp } from "@opencode-ai/schema/mcp" +import { Location } from "@opencode-ai/schema/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { LocationQuery, locationQueryOpenApi } from "./location" + +export const McpGroup = HttpApiGroup.make("server.mcp") + .add( + HttpApiEndpoint.get("mcp.list", "/api/mcp", { + query: LocationQuery, + success: Location.response(Schema.Array(Mcp.Server)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.mcp.list", + summary: "List MCP servers", + description: "Retrieve configured MCP servers and their connection status.", + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "mcp", description: "MCP server status routes." })) diff --git a/packages/schema/src/mcp-event.ts b/packages/schema/src/mcp-event.ts index 1d050df927..753dea2ef7 100644 --- a/packages/schema/src/mcp-event.ts +++ b/packages/schema/src/mcp-event.ts @@ -18,4 +18,13 @@ export const BrowserOpenFailed = Event.define({ }, }) -export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed) +// Emitted whenever a server's connection status settles (connected, failed, needs_auth, closed) so +// observers can refresh status without polling. +export const StatusChanged = Event.define({ + type: "mcp.status.changed", + schema: { + server: Schema.String, + }, +}) + +export const Definitions = Event.inventory(ToolsChanged, BrowserOpenFailed, StatusChanged) diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts new file mode 100644 index 0000000000..7c055a0ab2 --- /dev/null +++ b/packages/schema/src/mcp.ts @@ -0,0 +1,39 @@ +export * as Mcp from "./mcp" + +import { Schema } from "effect" + +const Connected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({ + identifier: "Mcp.Status.Connected", +}) +const Disconnected = Schema.Struct({ status: Schema.Literal("disconnected") }).annotate({ + identifier: "Mcp.Status.Disconnected", +}) +const Disabled = Schema.Struct({ status: Schema.Literal("disabled") }).annotate({ + identifier: "Mcp.Status.Disabled", +}) +const Failed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }).annotate({ + identifier: "Mcp.Status.Failed", +}) +const NeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({ + identifier: "Mcp.Status.NeedsAuth", +}) +const NeedsClientRegistration = Schema.Struct({ + status: Schema.Literal("needs_client_registration"), + error: Schema.String, +}).annotate({ identifier: "Mcp.Status.NeedsClientRegistration" }) + +export type Status = typeof Status.Type +export const Status = Schema.Union([ + Connected, + Disconnected, + Disabled, + Failed, + NeedsAuth, + NeedsClientRegistration, +]).pipe(Schema.toTaggedUnion("status")) + +export interface Server extends Schema.Schema.Type<typeof Server> {} +export const Server = Schema.Struct({ + name: Schema.String, + status: Status, +}).annotate({ identifier: "Mcp.Server" }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f567f48ed1..3039b1f82d 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -299,6 +299,8 @@ import type { V2IntegrationListResponses, V2LocationGetErrors, V2LocationGetResponses, + V2McpListErrors, + V2McpListResponses, V2ModelListErrors, V2ModelListResponses, V2PermissionRequestListErrors, @@ -6412,6 +6414,30 @@ export class Integration extends HeyApiClient { } } +export class Mcp2 extends HeyApiClient { + /** + * List MCP servers + * + * Retrieve configured MCP servers and their connection status. + */ + public list<ThrowOnError extends boolean = false>( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options<never, ThrowOnError>, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get<V2McpListResponses, V2McpListErrors, ThrowOnError>({ + url: "/api/mcp", + ...options, + ...params, + }) + } +} + export class Credential extends HeyApiClient { /** * Remove credential @@ -7435,6 +7461,11 @@ export class V2 extends HeyApiClient { return (this._integration ??= new Integration({ client: this.client })) } + private _mcp?: Mcp2 + get mcp(): Mcp2 { + return (this._mcp ??= new Mcp2({ client: this.client })) + } + private _credential?: Credential get credential(): Credential { return (this._credential ??= new Credential({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 57487cf2f7..4788320adc 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -84,6 +84,7 @@ export type Event = | EventTuiSessionSelect2 | EventMcpToolsChanged | EventMcpBrowserOpenFailed + | EventMcpStatusChanged | EventCommandExecuted | EventProjectUpdated | EventSessionStatus @@ -1551,6 +1552,13 @@ export type GlobalEvent = { url: string } } + | { + id: string + type: "mcp.status.changed" + properties: { + server: string + } + } | { id: string type: "command.executed" @@ -3048,6 +3056,7 @@ export type V2Event = | TuiSessionSelect | McpToolsChanged | McpBrowserOpenFailed + | McpStatusChanged | CommandExecuted | ProjectUpdated | SessionStatus2 @@ -5254,6 +5263,43 @@ export type IntegrationAttemptStatus = } } +export type McpStatusConnected2 = { + status: "connected" +} + +export type McpStatusDisconnected = { + status: "disconnected" +} + +export type McpStatusDisabled2 = { + status: "disabled" +} + +export type McpStatusFailed2 = { + status: "failed" + error: string +} + +export type McpStatusNeedsAuth2 = { + status: "needs_auth" +} + +export type McpStatusNeedsClientRegistration2 = { + status: "needs_client_registration" + error: string +} + +export type McpServer = { + name: string + status: + | McpStatusConnected2 + | McpStatusDisconnected + | McpStatusDisabled2 + | McpStatusFailed2 + | McpStatusNeedsAuth2 + | McpStatusNeedsClientRegistration2 +} + export type ProjectCurrent = { id: string directory: string @@ -6215,6 +6261,23 @@ export type McpBrowserOpenFailed = { } } +export type McpStatusChanged = { + id: string + metadata?: { + [key: string]: unknown + } + type: "mcp.status.changed" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + server: string + } +} + export type CommandExecuted = { id: string metadata?: { @@ -7318,6 +7381,14 @@ export type EventMcpBrowserOpenFailed = { } } +export type EventMcpStatusChanged = { + id: string + type: "mcp.status.changed" + properties: { + server: string + } +} + export type EventCommandExecuted = { id: string type: "command.executed" @@ -13030,6 +13101,43 @@ export type V2IntegrationAttemptCompleteResponses = { export type V2IntegrationAttemptCompleteResponse = V2IntegrationAttemptCompleteResponses[keyof V2IntegrationAttemptCompleteResponses] +export type V2McpListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/mcp" +} + +export type V2McpListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2McpListError = V2McpListErrors[keyof V2McpListErrors] + +export type V2McpListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array<McpServer> + } +} + +export type V2McpListResponse = V2McpListResponses[keyof V2McpListResponses] + export type V2CredentialRemoveData = { body?: never path: { diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index b7269495fd..acb16fb8e4 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -17,6 +17,7 @@ import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" +import { McpHandler } from "./handlers/mcp" import { CredentialHandler } from "./handlers/credential" import { ProjectHandler } from "./handlers/project" import { ProjectCopyHandler } from "./handlers/project-copy" @@ -31,6 +32,7 @@ export const handlers = Layer.mergeAll( GenerateHandler, ProviderHandler, IntegrationHandler, + McpHandler, CredentialHandler, ProjectHandler, PermissionHandler, diff --git a/packages/server/src/handlers/mcp.ts b/packages/server/src/handlers/mcp.ts new file mode 100644 index 0000000000..f488ed718e --- /dev/null +++ b/packages/server/src/handlers/mcp.ts @@ -0,0 +1,19 @@ +import { MCP } from "@opencode-ai/core/mcp/index" +import { Effect } from "effect" +import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Api } from "../api" +import { response } from "../location" + +export const McpHandler = HttpApiBuilder.group(Api, "server.mcp", (handlers) => + Effect.gen(function* () { + return handlers.handle( + "mcp.list", + Effect.fn(function* () { + const service = yield* MCP.Service + return yield* response( + service.servers().pipe(Effect.map((servers) => servers.map((info) => ({ name: info.name, status: info.status })))), + ) + }), + ) + }), +) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 820a069e83..86f0edcac3 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -376,6 +376,34 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi const attention = createTuiAttention({ renderer, config: tuiConfig, kv }) const clipboard = useClipboard() + // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, + // without having to open the status panel. Tracking the last alerted status avoids re-toasting + // the same problem on every refresh while still re-alerting if the state changes. + const mcpAlerted: Record<string, string> = {} + createEffect(() => { + for (const server of data.location.mcp.list() ?? []) { + const status = server.status + if (status.status !== "failed" && status.status !== "needs_auth") { + delete mcpAlerted[server.name] + continue + } + if (mcpAlerted[server.name] === status.status) continue + mcpAlerted[server.name] = status.status + if (status.status === "needs_auth") + toast.show({ + variant: "warning", + title: "MCP server needs authentication", + message: `Connect "${server.name}" to use its tools.`, + }) + else + toast.show({ + variant: "error", + title: "MCP server failed to connect", + message: `${server.name}: ${status.error}`, + }) + } + }) + const api = createTuiApi( createTuiApiAdapters({ version: InstallationVersion, diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index c48c0f8ee1..f5f82f2202 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -1,84 +1,53 @@ import { createMemo, createSignal } from "solid-js" -import { useLocal } from "../context/local" -import { useSync } from "../context/sync" -import { map, pipe, entries, sortBy } from "remeda" -import { DialogSelect, type DialogSelectRef, type DialogSelectOption } from "../ui/dialog-select" +import { useData } from "../context/data" +import { map, pipe, sortBy } from "remeda" +import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" -import { useSDK } from "../context/sdk" +import type { McpServer } from "@opencode-ai/sdk/v2" -function Status(props: { enabled: boolean; loading: boolean }) { +function Status(props: { status: McpServer["status"] }) { const { theme } = useTheme() - if (props.loading) { - return <span style={{ fg: theme.textMuted }}>⋯ Loading</span> + switch (props.status.status) { + case "connected": + return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}>✓ Connected</span> + case "failed": + return <span style={{ fg: theme.error }}>✗ {props.status.error}</span> + case "needs_auth": + return <span style={{ fg: theme.warning }}>! Needs authentication</span> + case "needs_client_registration": + return <span style={{ fg: theme.error }}>✗ {props.status.error}</span> + case "disabled": + return <span style={{ fg: theme.textMuted }}>○ Disabled</span> + default: + return <span style={{ fg: theme.textMuted }}>○ Disconnected</span> } - if (props.enabled) { - return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}>✓ Enabled</span> - } - return <span style={{ fg: theme.textMuted }}>○ Disabled</span> } export function DialogMcp() { - const local = useLocal() - const sync = useSync() - const sdk = useSDK() + const data = useData() const [, setRef] = createSignal<DialogSelectRef<unknown>>() - const [loading, setLoading] = createSignal<string | null>(null) - const options = createMemo(() => { - // Track sync data and loading state to trigger re-render when they change - const mcpData = sync.data.mcp - const loadingMcp = loading() - - return pipe( - mcpData ?? {}, - entries(), - sortBy(([name]) => name), - map(([name, status]) => ({ - value: name, - title: name, - description: status.status === "failed" ? "failed" : status.status, - footer: <Status enabled={local.mcp.isEnabled(name)} loading={loadingMcp === name} />, + const options = createMemo(() => + pipe( + data.location.mcp.list() ?? [], + sortBy((server) => server.name), + map((server) => ({ + value: server.name, + title: server.name, + footer: <Status status={server.status} />, category: undefined, })), - ) - }) - - const actions = createMemo(() => [ - { - command: "dialog.mcp.toggle", - title: "toggle", - onTrigger: async (option: DialogSelectOption<string>) => { - // Prevent toggling while an operation is already in progress - if (loading() !== null) return - - setLoading(option.value) - try { - await local.mcp.toggle(option.value) - // Refresh MCP status from server - const status = await sdk.client.mcp.status() - if (status.data) { - sync.set("mcp", status.data) - } else { - console.error("Failed to refresh MCP status: no data returned") - } - } catch (error) { - console.error("Failed to toggle MCP:", error) - } finally { - setLoading(null) - } - }, - }, - ]) + ), + ) return ( <DialogSelect ref={setRef} title="MCPs" options={options()} - actions={actions()} - onSelect={(_option) => { - // Don't close on select, only on escape + onSelect={() => { + // Read-only view: selection does nothing, the dialog closes on escape. }} /> ) diff --git a/packages/tui/src/component/dialog-status.tsx b/packages/tui/src/component/dialog-status.tsx index 6c8fabdbb3..1446dfc4ae 100644 --- a/packages/tui/src/component/dialog-status.tsx +++ b/packages/tui/src/component/dialog-status.tsx @@ -3,15 +3,18 @@ import { fileURLToPath } from "bun" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" import { useSync } from "../context/sync" +import { useData } from "../context/data" import { For, Match, Switch, Show, createMemo } from "solid-js" export type DialogStatusProps = {} export function DialogStatus() { const sync = useSync() + const data = useData() const { theme } = useTheme() const dialog = useDialog() + const mcp = createMemo(() => data.location.mcp.list() ?? []) const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) const plugins = createMemo(() => { @@ -50,11 +53,11 @@ export function DialogStatus() { esc </text> </box> - <Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}> + <Show when={mcp().length > 0} fallback={<text fg={theme.text}>No MCP Servers</text>}> <box> - <text fg={theme.text}>{Object.keys(sync.data.mcp).length} MCP Servers</text> - <For each={Object.entries(sync.data.mcp)}> - {([key, item]) => ( + <text fg={theme.text}>{mcp().length} MCP Servers</text> + <For each={mcp()}> + {(item) => ( <box flexDirection="row" gap={1}> <text flexShrink={0} @@ -67,22 +70,20 @@ export function DialogStatus() { needs_auth: theme.warning, needs_client_registration: theme.error, } as Record<string, typeof theme.success> - )[item.status], + )[item.status.status], }} > • </text> <text fg={theme.text} wrapMode="word"> - <b>{key}</b>{" "} + <b>{item.name}</b>{" "} <span style={{ fg: theme.textMuted }}> - <Switch fallback={item.status}> - <Match when={item.status === "connected"}>Connected</Match> - <Match when={item.status === "failed" && item}>{(val) => val().error}</Match> - <Match when={item.status === "disabled"}>Disabled in configuration</Match> - <Match when={(item.status as string) === "needs_auth"}> - Needs authentication (run: opencode mcp auth {key}) - </Match> - <Match when={(item.status as string) === "needs_client_registration" && item}> + <Switch fallback={item.status.status}> + <Match when={item.status.status === "connected"}>Connected</Match> + <Match when={item.status.status === "failed" && item.status}>{(val) => val().error}</Match> + <Match when={item.status.status === "disabled"}>Disabled in configuration</Match> + <Match when={item.status.status === "needs_auth"}>Needs authentication</Match> + <Match when={item.status.status === "needs_client_registration" && item.status}> {(val) => (val() as { error: string }).error} </Match> </Switch> diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 12ba3a7f3a..6c44d0fc01 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -3,6 +3,7 @@ import type { CommandV2Info, IntegrationInfo, LocationRef, + McpServer, ModelV2Info, PermissionSavedInfo, PermissionV2Request, @@ -30,6 +31,7 @@ type LocationData = { agent?: AgentV2Info[] command?: CommandV2Info[] integration?: IntegrationInfo[] + mcp?: McpServer[] model?: ModelV2Info[] provider?: ProviderV2Info[] reference?: ReferenceInfo[] @@ -529,6 +531,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.provider.refresh(event.location), ]) break + // Authenticating an MCP integration reconnects its server, which emits mcp.status.changed, + // so the mcp list refreshes here rather than off integration.updated. + case "mcp.status.changed": + void result.location.mcp.refresh(event.location) + break } } @@ -674,6 +681,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setStore("location", key, { ...store.location[key], integration: mutable(result.data) }) }, }, + mcp: { + list(location?: LocationRef) { + return store.location[locationKey(location ?? defaultLocation())]?.mcp + }, + async refresh(ref?: LocationRef) { + const result = await sdk.client.v2.mcp.list({ location: locationQuery(ref) }, { throwOnError: true }) + const key = locationKey(result.data.location) + setStore("location", key, { ...store.location[key], mcp: result.data.data }) + }, + }, model: { list(location?: LocationRef) { return store.location[locationKey(location ?? defaultLocation())]?.model @@ -747,6 +764,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ result.location.refresh(), result.location.agent.refresh(), result.location.integration.refresh(), + result.location.mcp.refresh(), result.location.model.refresh(), result.location.provider.refresh(), result.location.reference.refresh(), diff --git a/packages/tui/src/routes/session/footer.tsx b/packages/tui/src/routes/session/footer.tsx index c3a96254e9..d163f21477 100644 --- a/packages/tui/src/routes/session/footer.tsx +++ b/packages/tui/src/routes/session/footer.tsx @@ -1,6 +1,7 @@ import { createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" import { useTheme } from "../../context/theme" import { useSync } from "../../context/sync" +import { useData } from "../../context/data" import { useDirectory } from "../../context/directory" import { useConnected } from "../../component/use-connected" import { createStore } from "solid-js/store" @@ -9,9 +10,10 @@ import { useRoute } from "../../context/route" export function Footer() { const { theme } = useTheme() const sync = useSync() + const data = useData() const route = useRoute() - const mcp = createMemo(() => Object.values(sync.data.mcp).filter((x) => x.status === "connected").length) - const mcpError = createMemo(() => Object.values(sync.data.mcp).some((x) => x.status === "failed")) + const mcp = createMemo(() => (data.location.mcp.list() ?? []).filter((x) => x.status.status === "connected").length) + const mcpError = createMemo(() => (data.location.mcp.list() ?? []).some((x) => x.status.status === "failed")) const lsp = createMemo(() => Object.keys(sync.data.lsp)) const permissions = createMemo(() => { if (route.data.type !== "session") return [] From 8f1db7d06d0141ec8545eab7fa2205103ea73caf Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:36:38 -0500 Subject: [PATCH 27/27] fix(tui): align cli model picker behavior (#34571) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> --- packages/tui/src/app.tsx | 6 +- .../tui/src/component/dialog-integration.tsx | 99 +++++++++++++++---- packages/tui/src/component/dialog-model.tsx | 65 +++++++----- .../test/cli/cmd/tui/model-options.test.ts | 46 ++++----- 4 files changed, 145 insertions(+), 71 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 86f0edcac3..969d1664ed 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -748,7 +748,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi suggested: !connected(), slashName: "connect", run: () => { - dialog.replace(() => <DialogIntegration />) + dialog.replace(() => ( + <DialogIntegration + onConnected={(providerID) => dialog.replace(() => <DialogModel providerID={providerID} />)} + /> + )) }, category: "Integration", }, diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx index a1ef0b05e7..3b4b922134 100644 --- a/packages/tui/src/component/dialog-integration.tsx +++ b/packages/tui/src/component/dialog-integration.tsx @@ -24,6 +24,7 @@ const INTEGRATION_PRIORITY: Record<string, number> = { type ConnectMethod = Exclude<IntegrationInfo["methods"][number], { type: "env" }> type IntegrationAttempt = IntegrationConnectOauthOutput["data"] +type OnIntegrationConnected = (providerID?: string) => void export function integrationOptions(list: IntegrationInfo[]) { return list.toSorted( @@ -52,7 +53,7 @@ export function connectionSummary(integration: IntegrationInfo) { .join(", ") } -export function DialogIntegration() { +export function DialogIntegration(props: { onConnected?: OnIntegrationConnected } = {}) { const data = useData() const dialog = useDialog() const { theme } = useTheme() @@ -70,8 +71,8 @@ export function DialogIntegration() { gutter: connected ? () => <text fg={theme.success}>✓</text> : undefined, onSelect: () => credentialConnections(integration).length - ? manageConnections(integration, methods, dialog) - : selectMethod(integration, methods, dialog), + ? manageConnections(integration, methods, dialog, props.onConnected) + : selectMethod(integration, methods, dialog, props.onConnected), } }), ) @@ -89,6 +90,7 @@ function manageConnections( integration: IntegrationInfo, methods: ConnectMethod[], dialog: ReturnType<typeof useDialog>, + onConnected?: OnIntegrationConnected, ) { dialog.replace(() => { const data = useData() @@ -103,7 +105,7 @@ function manageConnections( { title: "Add connection", value: "add", - onSelect: () => selectMethod(integration, methods, dialog), + onSelect: () => selectMethod(integration, methods, dialog, onConnected), }, ] : []), @@ -123,29 +125,43 @@ function manageConnections( }) } -function selectMethod(integration: IntegrationInfo, methods: ConnectMethod[], dialog: ReturnType<typeof useDialog>) { - if (methods.length === 1) return openMethod(integration, methods[0], dialog) +function selectMethod( + integration: IntegrationInfo, + methods: ConnectMethod[], + dialog: ReturnType<typeof useDialog>, + onConnected?: OnIntegrationConnected, +) { + if (methods.length === 1) return openMethod(integration, methods[0], dialog, onConnected) dialog.replace(() => ( <DialogSelect title={`Connect ${integration.name}`} options={methods.map((method) => ({ title: method.type === "key" ? (method.label ?? "API key") : method.label, value: method.type === "key" ? "key" : method.id, - onSelect: () => openMethod(integration, method, dialog), + onSelect: () => openMethod(integration, method, dialog, onConnected), }))} /> )) } -function openMethod(integration: IntegrationInfo, method: ConnectMethod, dialog: ReturnType<typeof useDialog>) { +function openMethod( + integration: IntegrationInfo, + method: ConnectMethod, + dialog: ReturnType<typeof useDialog>, + onConnected?: OnIntegrationConnected, +) { if (method.type === "key") { - dialog.replace(() => <KeyMethod integration={integration} method={method} />) + dialog.replace(() => <KeyMethod integration={integration} method={method} onConnected={onConnected} />) return } - void beginOAuth(integration, method, dialog) + void beginOAuth(integration, method, dialog, onConnected) } -function KeyMethod(props: { integration: IntegrationInfo; method: Extract<ConnectMethod, { type: "key" }> }) { +function KeyMethod(props: { + integration: IntegrationInfo + method: Extract<ConnectMethod, { type: "key" }> + onConnected?: OnIntegrationConnected +}) { const data = useData() const dialog = useDialog() const sdk = useSDK() @@ -165,7 +181,7 @@ function KeyMethod(props: { integration: IntegrationInfo; method: Extract<Connec location: location(data), key, }) - .then(() => connected(props.integration.name, data, dialog, toast)) + .then(() => connected(props.integration, data, dialog, toast, props.onConnected)) .catch((cause) => setError(message(cause))) }} description={() => <Show when={error()}>{(value) => <text fg={theme.error}>{value()}</text>}</Show>} @@ -177,16 +193,20 @@ async function beginOAuth( integration: IntegrationInfo, method: IntegrationOAuthMethod, dialog: ReturnType<typeof useDialog>, + onConnected?: OnIntegrationConnected, ) { const inputs = method.prompts?.length ? await promptInputs(dialog, method.prompts) : {} if (inputs === null) return - dialog.replace(() => <OAuthStarting integration={integration} method={method} inputs={inputs} />) + dialog.replace(() => ( + <OAuthStarting integration={integration} method={method} inputs={inputs} onConnected={onConnected} /> + )) } function OAuthStarting(props: { integration: IntegrationInfo method: IntegrationOAuthMethod inputs: Record<string, string> + onConnected?: OnIntegrationConnected }) { const data = useData() const dialog = useDialog() @@ -204,12 +224,22 @@ function OAuthStarting(props: { .then((result) => { if (result.data.mode === "code") { dialog.replace(() => ( - <OAuthCode integration={props.integration} title={props.method.label} attempt={result.data} /> + <OAuthCode + integration={props.integration} + title={props.method.label} + attempt={result.data} + onConnected={props.onConnected} + /> )) return } dialog.replace(() => ( - <OAuthAuto integration={props.integration} title={props.method.label} attempt={result.data} /> + <OAuthAuto + integration={props.integration} + title={props.method.label} + attempt={result.data} + onConnected={props.onConnected} + /> )) }) .catch((cause) => { @@ -221,7 +251,12 @@ function OAuthStarting(props: { return <OAuthView title={props.method.label} message="Starting authorization..." /> } -function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) { +function OAuthAuto(props: { + integration: IntegrationInfo + title: string + attempt: IntegrationAttempt + onConnected?: OnIntegrationConnected +}) { const data = useData() const dialog = useDialog() const sdk = useSDK() @@ -258,7 +293,7 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt } settled = true if (status.status === "complete") { - void connected(props.integration.name, data, dialog, toast) + void connected(props.integration, data, dialog, toast, props.onConnected) return } toast.show({ variant: "error", message: status.status === "failed" ? status.message : "Authorization expired" }) @@ -289,7 +324,12 @@ function OAuthAuto(props: { integration: IntegrationInfo; title: string; attempt ) } -function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt: IntegrationAttempt }) { +function OAuthCode(props: { + integration: IntegrationInfo + title: string + attempt: IntegrationAttempt + onConnected?: OnIntegrationConnected +}) { const data = useData() const dialog = useDialog() const sdk = useSDK() @@ -313,7 +353,7 @@ function OAuthCode(props: { integration: IntegrationInfo; title: string; attempt .attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code }) .then(() => { settled = true - return connected(props.integration.name, data, dialog, toast) + return connected(props.integration, data, dialog, toast, props.onConnected) }) .catch((cause) => setError(message(cause))) }} @@ -407,20 +447,37 @@ async function promptInputs( } async function connected( - name: string, + integration: IntegrationInfo, data: ReturnType<typeof useData>, dialog: ReturnType<typeof useDialog>, toast: ReturnType<typeof useToast>, + onConnected?: OnIntegrationConnected, ) { await Promise.all([ data.location.integration.refresh(), data.location.model.refresh(), data.location.provider.refresh(), ]) - toast.show({ variant: "success", message: `Connected ${name}` }) + toast.show({ variant: "success", message: `Connected ${integration.name}` }) + if (onConnected) { + onConnected(providerID(data, integration.id)) + return + } dialog.clear() } +function providerID(data: ReturnType<typeof useData>, integrationID: string) { + const models = data.location.model.list() ?? [] + const matches = (data.location.provider.list() ?? []).filter( + (provider) => provider.integrationID === integrationID || provider.id === integrationID, + ) + return ( + matches.find((provider) => + models.some((model) => model.providerID === provider.id && model.status !== "deprecated"), + )?.id ?? matches[0]?.id + ) +} + async function disconnected( name: string, data: ReturnType<typeof useData>, diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index 065338f1cd..fcbc472965 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -1,6 +1,5 @@ import { createMemo, createSignal } from "solid-js" import { useLocal } from "../context/local" -import { sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { DialogIntegration } from "./dialog-integration" @@ -62,19 +61,24 @@ export function DialogModel(props: { providerID?: string }) { models() .filter((model) => model.status !== "deprecated") .filter((model) => (props.providerID ? model.providerID === props.providerID : true)) - .map((model) => ({ - value: { providerID: model.providerID, modelID: model.id }, - title: model.name, - releaseDate: model.time.released, - description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id) - ? "(Favorite)" - : undefined, - category: connected() ? (providers().get(model.providerID)?.name ?? model.providerID) : undefined, - footer: free(model) ? "Free" : undefined, - onSelect() { - onSelect(model.providerID, model.id) - }, - })) + .map((model) => { + const provider = providers().get(model.providerID) + return { + value: { providerID: model.providerID, modelID: model.id }, + providerID: model.providerID, + providerName: provider?.name ?? model.providerID, + title: model.name, + releaseDate: model.time.released, + description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id) + ? "(Favorite)" + : undefined, + category: connected() ? (provider?.name ?? model.providerID) : undefined, + footer: free(model) ? "Free" : undefined, + onSelect() { + onSelect(model.providerID, model.id) + }, + } + }) .filter((option) => { if (!showSections) return true if ( @@ -89,7 +93,6 @@ export function DialogModel(props: { providerID?: string }) { return false return true }), - props.providerID !== undefined, ) if (needle) { @@ -130,7 +133,11 @@ export function DialogModel(props: { providerID?: string }) { command: "model.dialog.provider", title: connected() ? "Connect integration" : "View all integrations", onTrigger() { - dialog.replace(() => <DialogIntegration />) + dialog.replace(() => ( + <DialogIntegration + onConnected={(providerID) => dialog.replace(() => <DialogModel providerID={providerID} />)} + /> + )) }, }, { @@ -151,17 +158,21 @@ export function DialogModel(props: { providerID?: string }) { ) } -export function sortModelOptions<T extends { footer?: string; releaseDate: string | number; title: string }>( - options: T[], - newestFirst: boolean, -) { - if (newestFirst) return sortBy(options, [(option) => option.releaseDate, "desc"], (option) => option.title) - return sortBy( - options, - (option) => option.footer !== "Free", - [(option) => option.releaseDate, "desc"], - (option) => option.title, - ) +export function sortModelOptions< + T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string }, +>(options: T[]) { + return options.toSorted((a, b) => { + const provider = Number(a.providerID !== "opencode") - Number(b.providerID !== "opencode") + if (provider !== 0) return provider + + const name = (a.providerName ?? "").localeCompare(b.providerName ?? "") + if (name !== 0) return name + + const release = Number(b.releaseDate) - Number(a.releaseDate) + if (release !== 0) return release + + return a.title.localeCompare(b.title) + }) } function free(model: { cost: Array<{ input: number }> }) { diff --git a/packages/tui/test/cli/cmd/tui/model-options.test.ts b/packages/tui/test/cli/cmd/tui/model-options.test.ts index 97bae7532f..38aa2cff8a 100644 --- a/packages/tui/test/cli/cmd/tui/model-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/model-options.test.ts @@ -2,31 +2,33 @@ import { describe, expect, test } from "bun:test" import { sortModelOptions } from "../../../../src/component/dialog-model" describe("sortModelOptions", () => { - test("orders provider-scoped model choices by newest release first", () => { - const sorted = sortModelOptions( - [ - { title: "GPT 5.2", releaseDate: "2025-12-11" }, - { title: "GPT 5.4", releaseDate: "2026-03-05" }, - { title: "GPT 5.1", releaseDate: "2025-11-13" }, - ], - true, - ) + test("orders opencode models before other providers", () => { + const sorted = sortModelOptions([ + { providerID: "openai", providerName: "OpenAI", releaseDate: 3, title: "GPT 5" }, + { providerID: "opencode", providerName: "OpenCode", releaseDate: 1, title: "Claude Sonnet 4" }, + { providerID: "anthropic", providerName: "Anthropic", releaseDate: 2, title: "Claude Opus 4" }, + ]) - expect(sorted.map((model) => model.title)).toEqual(["GPT 5.4", "GPT 5.2", "GPT 5.1"]) + expect(sorted.map((model) => model.title)).toEqual(["Claude Sonnet 4", "Claude Opus 4", "GPT 5"]) }) - test("orders regular model choices free-first and then newest-first", () => { - const sorted = sortModelOptions( - [ - { title: "GLM 5", releaseDate: "2025-07-28" }, - { title: "GLM 5.1", releaseDate: "2025-12-09" }, - { title: "GLM 5.2", releaseDate: "2026-02-16" }, - { title: "Free old", releaseDate: "2024-01-01", footer: "Free" }, - { title: "Free new", releaseDate: "2025-01-01", footer: "Free" }, - ], - false, - ) + test("orders provider groups by provider name and models by newest release", () => { + const sorted = sortModelOptions([ + { providerID: "google", providerName: "Google", releaseDate: 5, title: "Gemini 2.5 Pro" }, + { providerID: "anthropic", providerName: "Anthropic", releaseDate: 4, title: "Claude Sonnet 4" }, + { providerID: "anthropic", providerName: "Anthropic", releaseDate: 6, title: "Claude Opus 4" }, + { providerID: "openai", providerName: "OpenAI", releaseDate: 7, title: "GPT 5" }, + ]) - expect(sorted.map((model) => model.title)).toEqual(["Free new", "Free old", "GLM 5.2", "GLM 5.1", "GLM 5"]) + expect(sorted.map((model) => model.title)).toEqual(["Claude Opus 4", "Claude Sonnet 4", "Gemini 2.5 Pro", "GPT 5"]) + }) + + test("falls back to title when release dates match within a provider", () => { + const sorted = sortModelOptions([ + { providerID: "anthropic", providerName: "Anthropic", releaseDate: 5, title: "Claude Sonnet 4" }, + { providerID: "anthropic", providerName: "Anthropic", releaseDate: 5, title: "Claude Opus 4" }, + ]) + + expect(sorted.map((model) => model.title)).toEqual(["Claude Opus 4", "Claude Sonnet 4"]) }) })