From a6b6395c8acd8ab096b344176f86c9aa2b8983a6 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 1 May 2026 11:33:44 +0200 Subject: [PATCH 001/178] fix(tui): gate logo subpixel rendering on truecolor support (#25265) --- packages/opencode/src/cli/cmd/tui/component/logo.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index bee104a35d..e3e8074cd1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,4 +1,5 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" +import { useRenderer } from "@opentui/solid" import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" import * as Sound from "@tui/util/sound" @@ -554,6 +555,7 @@ function buildIdleState(t: number, ctx: LogoContext): IdleState { export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) { const ctx = props.shape ? build(props.shape) : DEFAULT const { theme } = useTheme() + const renderer = useRenderer() const [rings, setRings] = createSignal([]) const [hold, setHold] = createSignal() const [release, setRelease] = createSignal() @@ -684,6 +686,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = }) const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined)) + const useSubpixelBlocks = () => renderer.capabilities?.rgb === true const renderLine = ( line: string, @@ -789,7 +792,7 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = } // Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values - if (char === "█") { + if (char === "█" && useSubpixelBlocks()) { return ( Date: Fri, 1 May 2026 18:05:06 +0800 Subject: [PATCH 002/178] fix: correct documentation typos (#25260) --- packages/opencode/src/sync/README.md | 6 +++--- packages/web/src/content/docs/providers.mdx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/sync/README.md b/packages/opencode/src/sync/README.md index 546cf3ced4..cb7e875648 100644 --- a/packages/opencode/src/sync/README.md +++ b/packages/opencode/src/sync/README.md @@ -94,7 +94,7 @@ Importantly, **sync events automatically re-publish as bus events**. This makes ### Event shape -- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event throught the bus. +- The shape of the events are slightly different. A sync event has the `type`, `id`, `seq`, `aggregateID`, and `data` fields. A bus event has the `type` and `properties` fields. `data` and `properties` are largely the same thing. This conversion is automatically handled when the sync system re-published the event through the bus. The reason for this is because sync events need to track more information. I chose not to copy the `properties` naming to more clearly disambiguate the event types. @@ -112,9 +112,9 @@ The system install projectors in `server/projectors.js`. It calls `SyncEvent.ini This allows you to "reshape" an event from the sync system before it's published to the bus. This should be avoided, but might be necessary for temporary backwards compat. -The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync even only contains the fields updated. We convert the event to contain to full object for backwards compatibility (but ideally we'd remove this). +The only time we use this is the `session.updated` event. Previously this event contained the entire session object. The sync event only contains the fields updated. We convert the event to contain the full object for backwards compatibility (but ideally we'd remove this). -It's very important that types are correct when working with events. Event definitions have a `schema` which carries the defintiion of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples: +It's very important that types are correct when working with events. Event definitions have a `schema` which carries the definition of the event shape (provided by a zod schema, inferred into a TypeScript type). Examples: ```ts // The schema from `Updated` typechecks the object correctly diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 8576ec3562..7c395022c1 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1767,7 +1767,7 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon ### STACKIT -STACKIT AI Model Serving provides fully managed soverign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure. +STACKIT AI Model Serving provides fully managed sovereign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure. 1. Head over to [STACKIT Portal](https://portal.stackit.cloud), navigate to **AI Model Serving**, and create an auth token for your project. From 8c79c58c4d6330016331b698717f50aa21523058 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 07:36:52 -0400 Subject: [PATCH 003/178] refactor: rename workspace adapters (#25272) --- packages/opencode/specs/effect/http-api.md | 4 +- packages/opencode/specs/effect/schema.md | 2 +- .../tui/component/dialog-workspace-create.tsx | 16 +-- .../src/control-plane/adapters/index.ts | 45 ++++++++ .../{adaptors => adapters}/worktree.ts | 4 +- .../src/control-plane/adaptors/index.ts | 45 -------- packages/opencode/src/control-plane/types.ts | 6 +- .../opencode/src/control-plane/workspace.ts | 26 ++--- packages/opencode/src/plugin/index.ts | 10 +- .../src/server/routes/control/workspace.ts | 18 ++-- .../instance/httpapi/groups/workspace.ts | 14 +-- .../instance/httpapi/handlers/workspace.ts | 8 +- .../httpapi/middleware/workspace-routing.ts | 6 +- packages/opencode/src/server/workspace.ts | 6 +- .../{adaptors.test.ts => adapters.test.ts} | 24 ++--- .../test/control-plane/workspace.test.ts | 102 +++++++++--------- ...ptor.test.ts => workspace-adapter.test.ts} | 4 +- .../server/httpapi-instance-context.test.ts | 8 +- .../test/server/httpapi-session.test.ts | 8 +- .../server/httpapi-workspace-routing.test.ts | 20 ++-- .../test/server/httpapi-workspace.test.ts | 28 ++--- packages/plugin/src/index.ts | 4 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 18 ++-- packages/sdk/js/src/v2/gen/types.gen.ts | 12 +-- packages/sdk/openapi.json | 12 +-- 25 files changed, 225 insertions(+), 225 deletions(-) create mode 100644 packages/opencode/src/control-plane/adapters/index.ts rename packages/opencode/src/control-plane/{adaptors => adapters}/worktree.ts (92%) delete mode 100644 packages/opencode/src/control-plane/adaptors/index.ts rename packages/opencode/test/control-plane/{adaptors.test.ts => adapters.test.ts} (68%) rename packages/opencode/test/plugin/{workspace-adaptor.test.ts => workspace-adapter.test.ts} (96%) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 8eda0595db..99b7f1b156 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -198,7 +198,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho | `project` | `bridged` | list, current, git init, update | | `file` | `bridged` partial | find text/file/symbol, list/content/status | | `mcp` | `bridged` | status, add, OAuth, connect/disconnect | -| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore | +| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore | | top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose | | experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list | | `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply | @@ -290,7 +290,7 @@ This checklist tracks bridge parity only. Checked routes are available through t ### Workspace Routes -- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors. +- [x] `GET /experimental/workspace/adapter` - list workspace adapters. - [x] `POST /experimental/workspace` - create workspace. - [x] `GET /experimental/workspace` - list workspaces. - [x] `GET /experimental/workspace/status` - workspace status. diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index c4f9769224..e755457e61 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -353,7 +353,7 @@ piecewise. - [ ] `src/cli/cmd/tui/event.ts` - [ ] `src/cli/ui.ts` - [ ] `src/command/index.ts` -- [x] `src/control-plane/adaptors/worktree.ts` +- [x] `src/control-plane/adapters/worktree.ts` - [x] `src/control-plane/types.ts` - [x] `src/control-plane/workspace.ts` - [ ] `src/file/index.ts` diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 009bb74d2c..0aa61c313a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" -type Adaptor = { +type Adapter = { type: string name: string description: string @@ -108,26 +108,26 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = const sdk = useSDK() const toast = useToast() const [creating, setCreating] = createSignal() - const [adaptors, setAdaptors] = createSignal() + const [adapters, setAdapters] = createSignal() onMount(() => { dialog.setSize("medium") void (async () => { const dir = sync.path.directory || sdk.directory - const url = new URL("/experimental/workspace/adaptor", sdk.url) + const url = new URL("/experimental/workspace/adapter", sdk.url) if (dir) url.searchParams.set("directory", dir) const res = await sdk .fetch(url) - .then((x) => x.json() as Promise) + .then((x) => x.json() as Promise) .catch(() => undefined) if (!res) { toast.show({ - message: "Failed to load workspace adaptors", + message: "Failed to load workspace adapters", variant: "error", }) return } - setAdaptors(res) + setAdapters(res) })() }) @@ -142,13 +142,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) = }, ] } - const list = adaptors() + const list = adapters() if (!list) { return [ { title: "Loading workspaces...", value: "loading" as const, - description: "Fetching available workspace adaptors", + description: "Fetching available workspace adapters", }, ] } diff --git a/packages/opencode/src/control-plane/adapters/index.ts b/packages/opencode/src/control-plane/adapters/index.ts new file mode 100644 index 0000000000..963e2a2ed5 --- /dev/null +++ b/packages/opencode/src/control-plane/adapters/index.ts @@ -0,0 +1,45 @@ +import type { ProjectID } from "@/project/schema" +import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types" +import { WorktreeAdapter } from "./worktree" + +const BUILTIN: Record = { + worktree: WorktreeAdapter, +} + +const state = new Map>() + +export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter { + const custom = state.get(projectID)?.get(type) + if (custom) return custom + + const builtin = BUILTIN[type] + if (builtin) return builtin + + throw new Error(`Unknown workspace adapter: ${type}`) +} + +export async function listAdapters(projectID: ProjectID): Promise { + const builtin = await Promise.all( + Object.entries(BUILTIN).map(async ([type, adapter]) => { + return { + type, + name: adapter.name, + description: adapter.description, + } + }), + ) + const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({ + type, + name: adapter.name, + description: adapter.description, + })) + return [...builtin, ...custom] +} + +// Plugins can be loaded per-project so we need to scope them. If you +// want to install a global one pass `ProjectID.global` +export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) { + const adapters = state.get(projectID) ?? new Map() + adapters.set(type, adapter) + state.set(projectID, adapters) +} diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts similarity index 92% rename from packages/opencode/src/control-plane/adaptors/worktree.ts rename to packages/opencode/src/control-plane/adapters/worktree.ts index de9618d302..af8f5d8d43 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -1,5 +1,5 @@ import { Schema } from "effect" -import { type WorkspaceAdaptor, WorkspaceInfo } from "../types" +import { type WorkspaceAdapter, WorkspaceInfo } from "../types" const WorktreeConfig = Schema.Struct({ name: WorkspaceInfo.fields.name, @@ -13,7 +13,7 @@ async function loadWorktree() { return { AppRuntime, Worktree } } -export const WorktreeAdaptor: WorkspaceAdaptor = { +export const WorktreeAdapter: WorkspaceAdapter = { name: "Worktree", description: "Create a git worktree", async configure(info) { diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts deleted file mode 100644 index c91f534b5a..0000000000 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ProjectID } from "@/project/schema" -import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types" -import { WorktreeAdaptor } from "./worktree" - -const BUILTIN: Record = { - worktree: WorktreeAdaptor, -} - -const state = new Map>() - -export function getAdaptor(projectID: ProjectID, type: string): WorkspaceAdaptor { - const custom = state.get(projectID)?.get(type) - if (custom) return custom - - const builtin = BUILTIN[type] - if (builtin) return builtin - - throw new Error(`Unknown workspace adaptor: ${type}`) -} - -export async function listAdaptors(projectID: ProjectID): Promise { - const builtin = await Promise.all( - Object.entries(BUILTIN).map(async ([type, adaptor]) => { - return { - type, - name: adaptor.name, - description: adaptor.description, - } - }), - ) - const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({ - type, - name: adaptor.name, - description: adaptor.description, - })) - return [...builtin, ...custom] -} - -// Plugins can be loaded per-project so we need to scope them. If you -// want to install a global one pass `ProjectID.global` -export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) { - const adaptors = state.get(projectID) ?? new Map() - adaptors.set(type, adaptor) - state.set(projectID, adaptors) -} diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index af16c04902..7f3aad7ed1 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -17,12 +17,12 @@ export const WorkspaceInfo = Schema.Struct({ .pipe(withStatics((s) => ({ zod: zod(s) }))) export type WorkspaceInfo = DeepMutable> -export const WorkspaceAdaptorEntry = Schema.Struct({ +export const WorkspaceAdapterEntry = Schema.Struct({ type: Schema.String, name: Schema.String, description: Schema.String, }).pipe(withStatics((s) => ({ zod: zod(s) }))) -export type WorkspaceAdaptorEntry = Schema.Schema.Type +export type WorkspaceAdapterEntry = Schema.Schema.Type export type Target = | { @@ -35,7 +35,7 @@ export type Target = headers?: HeadersInit } -export type WorkspaceAdaptor = { +export type WorkspaceAdapter = { name: string description: string configure(info: WorkspaceInfo): WorkspaceInfo | Promise diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 7f9d078bb7..7e4b4a6ff4 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -16,7 +16,7 @@ import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "./workspace.sql" -import { getAdaptor } from "./adaptors" +import { getAdapter } from "./adapters" import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" @@ -335,8 +335,8 @@ export const layer = Layer.effect( }) const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { - const adaptor = getAdaptor(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) + const adapter = getAdapter(space.projectID, space.type) + const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) if (target.type === "local") return @@ -419,8 +419,8 @@ export const layer = Layer.effect( const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return - const adaptor = getAdaptor(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) + const adapter = getAdapter(space.projectID, space.type) + const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) if (target.type === "local") { setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") @@ -458,9 +458,9 @@ export const layer = Layer.effect( const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const id = WorkspaceID.ascending(input.id) - const adaptor = getAdaptor(input.projectID, input.type) + const adapter = getAdapter(input.projectID, input.type) const config = yield* Effect.promise(() => - Promise.resolve(adaptor.configure({ ...input, id, name: Slug.create(), directory: null })), + Promise.resolve(adapter.configure({ ...input, id, name: Slug.create(), directory: null })), ) const info: Info = { @@ -496,7 +496,7 @@ export const layer = Layer.effect( OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } - yield* Effect.promise(() => adaptor.create(config, env)) + yield* Effect.promise(() => adapter.create(config, env)) yield* Effect.all( [ waitEvent({ @@ -531,8 +531,8 @@ export const layer = Layer.effect( workspaceID: input.workspaceID, }) - const adaptor = getAdaptor(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adaptor.target(space))) + const adapter = getAdapter(space.projectID, space.type) + const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) yield* sync.run(Session.Event.Updated, { sessionID: input.sessionID, @@ -726,12 +726,12 @@ export const layer = Layer.effect( const info = fromRow(row) yield* Effect.catch( Effect.gen(function* () { - const adaptor = getAdaptor(info.projectID, row.type) - yield* Effect.tryPromise(() => Promise.resolve(adaptor.remove(info))) + const adapter = getAdapter(info.projectID, row.type) + yield* Effect.tryPromise(() => Promise.resolve(adapter.remove(info))) }), () => Effect.sync(() => { - log.error("adaptor not available when removing workspace", { type: row.type }) + log.error("adapter not available when removing workspace", { type: row.type }) }), ) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index ac37823c34..95af410ff9 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -3,7 +3,7 @@ import type { PluginInput, Plugin as PluginInstance, PluginModule, - WorkspaceAdaptor as PluginWorkspaceAdaptor, + WorkspaceAdapter as PluginWorkspaceAdapter, } from "@opencode-ai/plugin" import { Config } from "@/config/config" import { Bus } from "../bus" @@ -24,8 +24,8 @@ import { InstanceState } from "@/effect/instance-state" import { errorMessage } from "@/util/error" import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" -import { registerAdaptor } from "@/control-plane/adaptors" -import type { WorkspaceAdaptor } from "@/control-plane/types" +import { registerAdapter } from "@/control-plane/adapters" +import type { WorkspaceAdapter } from "@/control-plane/types" const log = Log.create({ service: "plugin" }) @@ -138,8 +138,8 @@ export const layer = Layer.effect( worktree: ctx.worktree, directory: ctx.directory, experimental_workspace: { - register(type: string, adaptor: PluginWorkspaceAdaptor) { - registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor) + register(type: string, adapter: PluginWorkspaceAdapter) { + registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter) }, }, get serverUrl(): URL { diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 08f926d40d..21a7810ce1 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -2,10 +2,10 @@ import { Hono } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import z from "zod" import { Effect } from "effect" -import { listAdaptors } from "@/control-plane/adaptors" +import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" -import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import { WorkspaceAdapterEntry } from "@/control-plane/types" import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" import { errors } from "../../error" @@ -18,24 +18,24 @@ const log = Log.create({ service: "server.workspace" }) export const WorkspaceRoutes = lazy(() => new Hono() .get( - "/adaptor", + "/adapter", describeRoute({ - summary: "List workspace adaptors", - description: "List all available workspace adaptors for the current project.", - operationId: "experimental.workspace.adaptor.list", + summary: "List workspace adapters", + description: "List all available workspace adapters for the current project.", + operationId: "experimental.workspace.adapter.list", responses: { 200: { - description: "Workspace adaptors", + description: "Workspace adapters", content: { "application/json": { - schema: resolver(z.array(zodObject(WorkspaceAdaptorEntry))), + schema: resolver(z.array(zodObject(WorkspaceAdapterEntry))), }, }, }, }, }), async (c) => { - return c.json(await listAdaptors(Instance.project.id)) + return c.json(await listAdapters(Instance.project.id)) }, ) .post( diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 268e84f2ec..112b8a3298 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -1,5 +1,5 @@ import { Workspace } from "@/control-plane/workspace" -import { WorkspaceAdaptorEntry } from "@/control-plane/types" +import { WorkspaceAdapterEntry } from "@/control-plane/types" import { NonNegativeInt } from "@/util/schema" import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -16,7 +16,7 @@ export const SessionRestoreResponse = Schema.Struct({ }) export const WorkspacePaths = { - adaptors: `${root}/adaptor`, + adapters: `${root}/adapter`, list: root, status: `${root}/status`, remove: `${root}/:id`, @@ -27,13 +27,13 @@ export const WorkspaceApi = HttpApi.make("workspace") .add( HttpApiGroup.make("workspace") .add( - HttpApiEndpoint.get("adaptors", WorkspacePaths.adaptors, { - success: described(Schema.Array(WorkspaceAdaptorEntry), "Workspace adaptors"), + HttpApiEndpoint.get("adapters", WorkspacePaths.adapters, { + success: described(Schema.Array(WorkspaceAdapterEntry), "Workspace adapters"), }).annotateMerge( OpenApi.annotations({ - identifier: "experimental.workspace.adaptor.list", - summary: "List workspace adaptors", - description: "List all available workspace adaptors for the current project.", + identifier: "experimental.workspace.adapter.list", + summary: "List workspace adapters", + description: "List all available workspace adapters for the current project.", }), ), HttpApiEndpoint.get("list", WorkspacePaths.list, { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 4e76a76a30..03e8ee74b7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -1,4 +1,4 @@ -import { listAdaptors } from "@/control-plane/adaptors" +import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" import { Effect } from "effect" @@ -10,9 +10,9 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac Effect.gen(function* () { const workspace = yield* Workspace.Service - const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () { + const adapters = Effect.fn("WorkspaceHttpApi.adapters")(function* () { const instance = yield* InstanceState.context - return yield* Effect.promise(() => listAdaptors(instance.project.id)) + return yield* Effect.promise(() => listAdapters(instance.project.id)) }) const list = Effect.fn("WorkspaceHttpApi.list")(function* () { @@ -51,7 +51,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac }) return handlers - .handle("adaptors", adaptors) + .handle("adapters", adapters) .handle("list", list) .handle("create", create) .handle("status", status) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 30edbc782b..f38c91ccec 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -1,4 +1,4 @@ -import { getAdaptor } from "@/control-plane/adaptors" +import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import type { Target } from "@/control-plane/types" import { Workspace } from "@/control-plane/workspace" @@ -89,8 +89,8 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe function resolveTarget(workspace: Workspace.Info): Effect.Effect { return Effect.gen(function* () { - const adaptor = yield* Effect.sync(() => getAdaptor(workspace.projectID, workspace.type)) - return yield* Effect.promise(() => Promise.resolve(adaptor.target(workspace))) + const adapter = yield* Effect.sync(() => getAdapter(workspace.projectID, workspace.type)) + return yield* Effect.promise(() => Promise.resolve(adapter.target(workspace))) }) } diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 667e610abc..f757137483 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -1,6 +1,6 @@ import type { MiddlewareHandler } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { getAdaptor } from "@/control-plane/adaptors" +import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" @@ -91,8 +91,8 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return next() } - const adaptor = getAdaptor(workspace.projectID, workspace.type) - const target = await adaptor.target(workspace) + const adapter = getAdapter(workspace.projectID, workspace.type) + const target = await adapter.target(workspace) if (target.type === "local") { return WorkspaceContext.provide({ diff --git a/packages/opencode/test/control-plane/adaptors.test.ts b/packages/opencode/test/control-plane/adapters.test.ts similarity index 68% rename from packages/opencode/test/control-plane/adaptors.test.ts rename to packages/opencode/test/control-plane/adapters.test.ts index a8e490226b..762bb5d57e 100644 --- a/packages/opencode/test/control-plane/adaptors.test.ts +++ b/packages/opencode/test/control-plane/adapters.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { getAdaptor, registerAdaptor } from "../../src/control-plane/adaptors" +import { getAdapter, registerAdapter } from "../../src/control-plane/adapters" import { ProjectID } from "../../src/project/schema" import type { WorkspaceInfo } from "../../src/control-plane/types" @@ -15,7 +15,7 @@ function info(projectID: WorkspaceInfo["projectID"], type: string): WorkspaceInf } } -function adaptor(dir: string) { +function adapter(dir: string) { return { name: dir, description: dir, @@ -33,19 +33,19 @@ function adaptor(dir: string) { } } -describe("control-plane/adaptors", () => { - test("isolates custom adaptors by project", async () => { +describe("control-plane/adapters", () => { + test("isolates custom adapters by project", async () => { const type = `demo-${Math.random().toString(36).slice(2)}` const one = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) const two = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) - registerAdaptor(one, type, adaptor("/one")) - registerAdaptor(two, type, adaptor("/two")) + registerAdapter(one, type, adapter("/one")) + registerAdapter(two, type, adapter("/two")) - expect(await (await getAdaptor(one, type)).target(info(one, type))).toEqual({ + expect(await (await getAdapter(one, type)).target(info(one, type))).toEqual({ type: "local", directory: "/one", }) - expect(await (await getAdaptor(two, type)).target(info(two, type))).toEqual({ + expect(await (await getAdapter(two, type)).target(info(two, type))).toEqual({ type: "local", directory: "/two", }) @@ -54,16 +54,16 @@ describe("control-plane/adaptors", () => { test("latest install wins within a project", async () => { const type = `demo-${Math.random().toString(36).slice(2)}` const id = ProjectID.make(`project-${Math.random().toString(36).slice(2)}`) - registerAdaptor(id, type, adaptor("/one")) + registerAdapter(id, type, adapter("/one")) - expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({ type: "local", directory: "/one", }) - registerAdaptor(id, type, adaptor("/two")) + registerAdapter(id, type, adapter("/two")) - expect(await (await getAdaptor(id, type)).target(info(id, type))).toEqual({ + expect(await (await getAdapter(id, type)).target(info(id, type))).toEqual({ type: "local", directory: "/two", }) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 594789b207..8545aef7f3 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -23,10 +23,10 @@ import { EventSequenceTable, EventTable } from "@/sync/event.sql" import { resetDatabase } from "../fixture/db" import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { registerAdaptor } from "../../src/control-plane/adaptors" +import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" -import type { Target, WorkspaceAdaptor, WorkspaceInfo } from "../../src/control-plane/types" +import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" import * as WorkspaceOld from "../../src/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" @@ -53,8 +53,8 @@ type RecordedCreate = { from?: WorkspaceInfo } -type RecordedAdaptor = { - adaptor: WorkspaceAdaptor +type RecordedAdapter = { + adapter: WorkspaceAdapter calls: { configure: WorkspaceInfo[] create: RecordedCreate[] @@ -165,13 +165,13 @@ function eventuallyEffect(effect: Effect.Effect, timeout = 1500) { }) } -function recordedAdaptor(input: { +function recordedAdapter(input: { target: (info: WorkspaceInfo) => Target | Promise configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise create?: (info: WorkspaceInfo, env: Record, from?: WorkspaceInfo) => Promise remove?: (info: WorkspaceInfo) => Promise -}): RecordedAdaptor { - const calls: RecordedAdaptor["calls"] = { +}): RecordedAdapter { + const calls: RecordedAdapter["calls"] = { configure: [], create: [], remove: [], @@ -180,7 +180,7 @@ function recordedAdaptor(input: { return { calls, - adaptor: { + adapter: { name: "recorded", description: "recorded", configure(info) { @@ -207,8 +207,8 @@ function recordedAdaptor(input: { } } -function localAdaptor(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise }) { - return recordedAdaptor({ +function localAdapter(dir: string, input?: { createDir?: boolean; remove?: (info: WorkspaceInfo) => Promise }) { + return recordedAdapter({ configure(info) { return { ...info, directory: dir } }, @@ -223,8 +223,8 @@ function localAdaptor(dir: string, input?: { createDir?: boolean; remove?: (info }) } -function remoteAdaptor(url: string, input?: { directory?: string | null; headers?: HeadersInit }) { - return recordedAdaptor({ +function remoteAdapter(url: string, input?: { directory?: string | null; headers?: HeadersInit }) { + return recordedAdapter({ configure(info) { return { ...info, directory: input?.directory ?? info.directory } }, @@ -429,7 +429,7 @@ describe("workspace-old CRUD", () => { const workspaceID = WorkspaceID.ascending("wrk_create_local") const type = unique("create-local") const targetDir = path.join(dir, "created-local") - const recorded = recordedAdaptor({ + const recorded = recordedAdapter({ configure(info) { return { ...info, @@ -446,7 +446,7 @@ describe("workspace-old CRUD", () => { return { type: "local", directory: targetDir } }, }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = await createWorkspace({ id: workspaceID, @@ -489,17 +489,17 @@ describe("workspace-old CRUD", () => { test("create propagates configure failures and does not insert a workspace", async () => { await withInstance(async () => { const type = unique("configure-failure") - registerAdaptor( + registerAdapter( Instance.project.id, type, - recordedAdaptor({ + recordedAdapter({ configure() { throw new Error("configure exploded") }, target() { return { type: "local", directory: "/unused" } }, - }).adaptor, + }).adapter, ) await expect( @@ -509,10 +509,10 @@ describe("workspace-old CRUD", () => { }) }) - test("create leaves the inserted row when adaptor create fails", async () => { + test("create leaves the inserted row when adapter create fails", async () => { await withInstance(async () => { const type = unique("create-failure") - const recorded = recordedAdaptor({ + const recorded = recordedAdapter({ async create() { throw new Error("create exploded") }, @@ -520,7 +520,7 @@ describe("workspace-old CRUD", () => { return { type: "local", directory: "/unused" } }, }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + registerAdapter(Instance.project.id, type, recorded.adapter) await expect( createWorkspace({ type, branch: "branch", projectID: Instance.project.id, extra: { x: 1 } }), @@ -538,8 +538,8 @@ describe("workspace-old CRUD", () => { await withInstance(async (dir) => { const type = unique("local-error") const missing = path.join(dir, "missing-local-target") - const recorded = localAdaptor(missing, { createDir: false }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + const recorded = localAdapter(missing, { createDir: false }) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) @@ -576,8 +576,8 @@ describe("workspace-old CRUD", () => { Effect.gen(function* () { const workspace = yield* WorkspaceOld.Service const type = unique("remote-create") - const recorded = remoteAdaptor(`${url}/base/?ignored=1#hash`, { directory: dir }) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + const recorded = remoteAdapter(`${url}/base/?ignored=1#hash`, { directory: dir }) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = yield* workspace.create({ type, branch: null, projectID: Instance.project.id, extra: null }) @@ -603,11 +603,11 @@ describe("workspace-old CRUD", () => { }) }) - test("remove deletes the workspace, associated sessions, adaptor resources, and status", async () => { + test("remove deletes the workspace, associated sessions, adapter resources, and status", async () => { await withInstance(async (dir) => { const type = unique("remove-local") - const recorded = localAdaptor(path.join(dir, "remove-local")) - registerAdaptor(Instance.project.id, type, recorded.adaptor) + const recorded = localAdapter(path.join(dir, "remove-local")) + registerAdapter(Instance.project.id, type, recorded.adapter) const info = await createWorkspace({ type, branch: null, projectID: Instance.project.id, extra: null }) const one = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) const two = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) @@ -628,21 +628,21 @@ describe("workspace-old CRUD", () => { }) }) - test("remove still deletes the row when the adaptor cannot remove resources", async () => { + test("remove still deletes the row when the adapter cannot remove resources", async () => { await withInstance(async () => { const type = unique("remove-throws") const info = workspaceInfo(Instance.project.id, type, { id: WorkspaceID.ascending("wrk_remove_throws") }) - registerAdaptor( + registerAdapter( Instance.project.id, type, - recordedAdaptor({ + recordedAdapter({ async remove() { throw new Error("remove exploded") }, target() { return { type: "local", directory: "/unused" } }, - }).adaptor, + }).adapter, ) insertWorkspace(info) @@ -661,7 +661,7 @@ describe("workspace-old sync state", () => { const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) attachSessionToWorkspace(session.id, info.id) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(path.join(dir, "flag-disabled")).adaptor) + registerAdapter(Instance.project.id, type, localAdapter(path.join(dir, "flag-disabled")).adapter) startWorkspaceSyncing(Instance.project.id) await delay(25) @@ -682,8 +682,8 @@ describe("workspace-old sync state", () => { await fs.mkdir(withoutSessionDir, { recursive: true }) insertWorkspace(withSession) insertWorkspace(withoutSession) - registerAdaptor(Instance.project.id, withSessionType, localAdaptor(withSessionDir).adaptor) - registerAdaptor(Instance.project.id, withoutSessionType, localAdaptor(withoutSessionDir).adaptor) + registerAdapter(Instance.project.id, withSessionType, localAdapter(withSessionDir).adapter) + registerAdapter(Instance.project.id, withoutSessionType, localAdapter(withoutSessionDir).adapter) attachSessionToWorkspace( (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, withSession.id, @@ -707,10 +707,10 @@ describe("workspace-old sync state", () => { const type = unique("missing-local") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor( + registerAdapter( Instance.project.id, type, - localAdaptor(path.join(dir, "missing-target"), { createDir: false }).adaptor, + localAdapter(path.join(dir, "missing-target"), { createDir: false }).adapter, ) attachSessionToWorkspace( (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, @@ -738,7 +738,7 @@ describe("workspace-old sync state", () => { const target = path.join(dir, "dedupe-local") await fs.mkdir(target, { recursive: true }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(target).adaptor) + registerAdapter(Instance.project.id, type, localAdapter(target).adapter) attachSessionToWorkspace( (await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({})))).id, info.id, @@ -795,7 +795,7 @@ describe("workspace-old sync state", () => { const type = unique("remote-start") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sync`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sync`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -850,7 +850,7 @@ describe("workspace-old sync state", () => { const type = unique("remote-connect-fail") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/failed`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/failed`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -890,7 +890,7 @@ describe("workspace-old sync state", () => { const type = unique("remote-history-fail") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history-failed`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history-failed`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -947,7 +947,7 @@ describe("workspace-old sync state", () => { const type = unique("history-replay") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/history`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/history`).adapter) const session = yield* sessionSvc.create({ title: "before history" }) attachSessionToWorkspace(session.id, info.id) historySessionID = session.id @@ -1014,7 +1014,7 @@ describe("workspace-old sync state", () => { const type = unique("sse-forward") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-forward`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-forward`).adapter) attachSessionToWorkspace((yield* sessionSvc.create({})).id, info.id) yield* workspace.startWorkspaceSyncing(Instance.project.id) @@ -1095,7 +1095,7 @@ describe("workspace-old sync state", () => { const type = unique("sse-sync") const info = workspaceInfo(Instance.project.id, type) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/sse-sync`).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/sse-sync`).adapter) const session = yield* sessionSvc.create({ title: "before sse" }) attachSessionToWorkspace(session.id, info.id) sseSessionID = session.id @@ -1232,7 +1232,7 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-missing-session") const info = workspaceInfo(Instance.project.id, type, { directory: dir }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) + registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) await expect( restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), @@ -1273,13 +1273,13 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-remote") const info = workspaceInfo(Instance.project.id, type, { directory: dir }) insertWorkspace(info) - registerAdaptor( + registerAdapter( Instance.project.id, type, - remoteAdaptor(`${url}/restore/?ignored=1#hash`, { + remoteAdapter(`${url}/restore/?ignored=1#hash`, { directory: dir, headers: { authorization: "Bearer restore" }, - }).adaptor, + }).adapter, ) const session = yield* sessionSvc.create({ title: "restore remote" }) replaceSessionEvents(session.id, 24) @@ -1353,7 +1353,7 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-null-dir") const info = workspaceInfo(Instance.project.id, type, { directory: null }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/null-dir`, { directory: null }).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter) const session = yield* sessionSvc.create({ title: "null dir" }) replaceSessionEvents(session.id, 0) @@ -1397,7 +1397,7 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-remote-fail") const info = workspaceInfo(Instance.project.id, type, { directory: dir }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/fail`, { directory: dir }).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter) const session = yield* sessionSvc.create({ title: "restore fail" }) replaceSessionEvents(session.id, 11) @@ -1437,7 +1437,7 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-local") const info = workspaceInfo(Instance.project.id, type, { directory: dir }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, localAdaptor(dir).adaptor) + registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) const session = yield* sessionSvc.create({ title: "restore local" }) replaceSessionEvents(session.id, 20) @@ -1488,7 +1488,7 @@ describe("workspace-old sessionRestore", () => { const type = unique("restore-real-events") const info = workspaceInfo(Instance.project.id, type, { directory: dir }) insertWorkspace(info) - registerAdaptor(Instance.project.id, type, remoteAdaptor(`${url}/real`, { directory: dir }).adaptor) + registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter) const session = yield* sessionSvc.create({ title: "real events" }) for (let i = 0; i < 3; i++) { const msg = yield* sessionSvc.updateMessage({ diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts similarity index 96% rename from packages/opencode/test/plugin/workspace-adaptor.test.ts rename to packages/opencode/test/plugin/workspace-adapter.test.ts index 677c004be4..9abf993d80 100644 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -34,7 +34,7 @@ afterAll(() => { }) describe("plugin.workspace", () => { - it.live("plugin can install a workspace adaptor", () => + it.live("plugin can install a workspace adapter", () => provideTmpdirInstance((dir) => Effect.gen(function* () { const type = `plug-${Math.random().toString(36).slice(2)}` @@ -48,7 +48,7 @@ describe("plugin.workspace", () => { "export default async ({ experimental_workspace }) => {", ` experimental_workspace.register(${JSON.stringify(type)}, {`, ' name: "plug",', - ' description: "plugin workspace adaptor",', + ' description: "plugin workspace adapter",', " configure(input) {", ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, " },", diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 28945f0213..9dea20dd66 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -7,8 +7,8 @@ import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "e import * as Socket from "effect/unstable/socket/Socket" import { mkdir } from "node:fs/promises" import path from "node:path" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" @@ -49,7 +49,7 @@ const instanceContextTestLayer = instanceRouterMiddleware .combine(workspaceRouterMiddleware) .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)) -const localAdaptor = (directory: string): WorkspaceAdaptor => ({ +const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", description: "Create a local test workspace", configure: (info) => ({ ...info, name: "local-test", directory }), @@ -63,7 +63,7 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => Effect.acquireRelease( Effect.gen(function* () { - registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) + registerAdapter(input.projectID, input.type, localAdapter(input.directory)) const workspace = yield* Workspace.Service return yield* workspace.create({ type: input.type, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 11e9d8b185..5f2af06f1e 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -3,8 +3,8 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" @@ -82,7 +82,7 @@ function createTextMessage(directory: string, sessionID: SessionID, text: string ) } -const localAdaptor = (directory: string): WorkspaceAdaptor => ({ +const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", description: "Create a local test workspace", configure: (info) => ({ ...info, name: "local-test", directory }), @@ -95,7 +95,7 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => Effect.gen(function* () { - registerAdaptor(input.projectID, input.type, localAdaptor(input.directory)) + registerAdapter(input.projectID, input.type, localAdapter(input.directory)) return yield* Workspace.Service.use((svc) => svc.create({ type: input.type, diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 5d92635fbc..b0b276841d 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -15,9 +15,9 @@ import * as Socket from "effect/unstable/socket/Socket" import Http from "node:http" import { mkdir } from "node:fs/promises" import path from "node:path" -import { registerAdaptor } from "../../src/control-plane/adaptors" +import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspaceTable } from "../../src/control-plane/workspace.sql" import { Project } from "../../src/project/project" @@ -82,7 +82,7 @@ const listenAdditionalServer = (handler: TestHandler) => return HttpServer.formatAddress(server.address) }) -const localAdaptor = (directory: string): WorkspaceAdaptor => ({ +const localAdapter = (directory: string): WorkspaceAdapter => ({ name: "Local Test", description: "Create a local test workspace", configure: (info) => ({ ...info, name: "local-test", directory }), @@ -93,7 +93,7 @@ const localAdaptor = (directory: string): WorkspaceAdaptor => ({ target: () => ({ type: "local" as const, directory }), }) -const remoteAdaptor = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor => ({ +const remoteAdapter = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter => ({ name: "Remote Test", description: "Create a remote test workspace", configure: (info) => ({ ...info, name: "remote-test", directory }), @@ -116,10 +116,10 @@ const syncResponse = (request: HttpServerRequest.HttpServerRequest) => { return undefined } -const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) => +const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adapter: WorkspaceAdapter }) => Effect.acquireRelease( Effect.gen(function* () { - registerAdaptor(input.projectID, input.type, input.adaptor) + registerAdapter(input.projectID, input.type, input.adapter) const workspace = yield* Workspace.Service return yield* workspace.create({ type: input.type, @@ -144,14 +144,14 @@ const createRemoteWorkspace = (input: { createWorkspace({ projectID: input.projectID, type: input.type, - adaptor: remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url, input.headers), + adapter: remoteAdapter(path.join(input.dir, `.${input.type}`), input.url, input.headers), }) const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) => createWorkspace({ projectID: input.projectID, type: input.type, - adaptor: localAdaptor(input.directory), + adapter: localAdapter(input.directory), }) const insertRemoteWorkspaceWithoutSync = (input: { @@ -162,7 +162,7 @@ const insertRemoteWorkspaceWithoutSync = (input: { }) => Effect.sync(() => { const id = WorkspaceID.ascending() - registerAdaptor(input.projectID, input.type, remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url)) + registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url)) Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run()) return id }) @@ -237,7 +237,7 @@ describe("HttpApi workspace routing middleware", () => { { status: 201, headers: { "x-remote": "yes" } }, ) }) - // The adaptor target tells the middleware where to proxy selected remote + // The adapter target tells the middleware where to proxy selected remote // workspace requests. Appending /probe to this base should produce // `${remoteUrl}/base/probe` on the fake remote server above. const workspace = yield* createRemoteWorkspace({ diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 6a04833e35..e44a5ee3cd 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -4,8 +4,8 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { registerAdaptor } from "../../src/control-plane/adaptors" -import type { WorkspaceAdaptor } from "../../src/control-plane/types" +import { registerAdapter } from "../../src/control-plane/adapters" +import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" import { Session } from "@/session/session" @@ -36,7 +36,7 @@ function request(path: string, directory: string, init: RequestInit = {}) { }) } -function localAdaptor(directory: string): WorkspaceAdaptor { +function localAdapter(directory: string): WorkspaceAdapter { return { name: "Local Test", description: "Create a local test workspace", @@ -60,7 +60,7 @@ function localAdaptor(directory: string): WorkspaceAdaptor { } } -function remoteAdaptor(directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor { +function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter { return { name: "Remote Test", description: "Create a remote test workspace", @@ -137,14 +137,14 @@ describe("workspace HttpApi", () => { Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) - const [adaptors, workspaces, status] = yield* Effect.all([ - request(WorkspacePaths.adaptors, dir), + const [adapters, workspaces, status] = yield* Effect.all([ + request(WorkspacePaths.adapters, dir), request(WorkspacePaths.list, dir), request(WorkspacePaths.status, dir), ]) - expect(adaptors.status).toBe(200) - expect(yield* Effect.promise(() => adaptors.json())).toContainEqual({ + expect(adapters.status).toBe(200) + expect(yield* Effect.promise(() => adapters.json())).toContainEqual({ type: "worktree", name: "Worktree", description: "Create a git worktree", @@ -163,7 +163,7 @@ describe("workspace HttpApi", () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true const dir = yield* tmpdirScoped({ git: true }) const project = yield* Project.use.fromDirectory(dir) - registerAdaptor(project.project.id, "local-test", localAdaptor(path.join(dir, ".workspace"))) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) const created = yield* request(WorkspacePaths.list, dir, { method: "POST", @@ -201,7 +201,7 @@ describe("workspace HttpApi", () => { const dir = yield* tmpdirScoped({ git: true }) const workspaceDir = path.join(dir, ".workspace-local") const project = yield* Project.use.fromDirectory(dir) - registerAdaptor(project.project.id, "local-target", localAdaptor(workspaceDir)) + registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir)) const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, @@ -250,10 +250,10 @@ describe("workspace HttpApi", () => { }) const project = yield* Project.use.fromDirectory(dir) - registerAdaptor( + registerAdapter( project.project.id, "remote-target", - remoteAdaptor(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, { + remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, { "x-target-auth": "secret", }), ) @@ -319,10 +319,10 @@ describe("workspace HttpApi", () => { }) const project = yield* Project.use.fromDirectory(dir) - registerAdaptor( + registerAdapter( project.project.id, "remote-session-target", - remoteAdaptor(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), + remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`), ) const created = yield* request(WorkspacePaths.list, dir, { method: "POST", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 71a3278cbb..2e96dd9801 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -45,7 +45,7 @@ export type WorkspaceTarget = headers?: HeadersInit } -export type WorkspaceAdaptor = { +export type WorkspaceAdapter = { name: string description: string configure(config: WorkspaceInfo): WorkspaceInfo | Promise @@ -60,7 +60,7 @@ export type PluginInput = { directory: string worktree: string experimental_workspace: { - register(type: string, adaptor: WorkspaceAdaptor): void + register(type: string, adapter: WorkspaceAdapter): void } serverUrl: URL $: BunShell diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2da7c865d7..67261d7499 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -29,7 +29,7 @@ import type { ExperimentalConsoleSwitchOrgResponses, ExperimentalResourceListResponses, ExperimentalSessionListResponses, - ExperimentalWorkspaceAdaptorListResponses, + ExperimentalWorkspaceAdapterListResponses, ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateResponses, ExperimentalWorkspaceListResponses, @@ -512,11 +512,11 @@ export class App extends HeyApiClient { } } -export class Adaptor extends HeyApiClient { +export class Adapter extends HeyApiClient { /** - * List workspace adaptors + * List workspace adapters * - * List all available workspace adaptors for the current project. + * List all available workspace adapters for the current project. */ public list( parameters?: { @@ -536,8 +536,8 @@ export class Adaptor extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace/adaptor", + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/adapter", ...options, ...params, }) @@ -731,9 +731,9 @@ export class Workspace extends HeyApiClient { }) } - private _adaptor?: Adaptor - get adaptor(): Adaptor { - return (this._adaptor ??= new Adaptor({ client: this.client })) + private _adapter?: Adapter + get adapter(): Adapter { + return (this._adapter ??= new Adapter({ 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 9bb1e50aac..b925ec6096 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2430,19 +2430,19 @@ export type AppLogResponses = { export type AppLogResponse = AppLogResponses[keyof AppLogResponses] -export type ExperimentalWorkspaceAdaptorListData = { +export type ExperimentalWorkspaceAdapterListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/adaptor" + url: "/experimental/workspace/adapter" } -export type ExperimentalWorkspaceAdaptorListResponses = { +export type ExperimentalWorkspaceAdapterListResponses = { /** - * Workspace adaptors + * Workspace adapters */ 200: Array<{ type: string @@ -2451,8 +2451,8 @@ export type ExperimentalWorkspaceAdaptorListResponses = { }> } -export type ExperimentalWorkspaceAdaptorListResponse = - ExperimentalWorkspaceAdaptorListResponses[keyof ExperimentalWorkspaceAdaptorListResponses] +export type ExperimentalWorkspaceAdapterListResponse = + ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] export type ExperimentalWorkspaceListData = { body?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 22e66c7d16..cfd8277a3b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -415,9 +415,9 @@ ] } }, - "/experimental/workspace/adaptor": { + "/experimental/workspace/adapter": { "get": { - "operationId": "experimental.workspace.adaptor.list", + "operationId": "experimental.workspace.adapter.list", "parameters": [ { "in": "query", @@ -434,11 +434,11 @@ } } ], - "summary": "List workspace adaptors", - "description": "List all available workspace adaptors for the current project.", + "summary": "List workspace adapters", + "description": "List all available workspace adapters for the current project.", "responses": { "200": { - "description": "Workspace adaptors", + "description": "Workspace adapters", "content": { "application/json": { "schema": { @@ -466,7 +466,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})" } ] } From 16ddf5f559d8c52b23c6db7a046c3fda6a1d71f6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 07:57:03 -0400 Subject: [PATCH 004/178] fix(session): use finite archived timestamp schema (#25275) --- packages/opencode/src/session/session.ts | 6 +++--- packages/opencode/test/server/httpapi-bridge.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 5593efc971..e1d0c527aa 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -142,9 +142,9 @@ const Share = Schema.Struct({ url: Schema.String, }) -// Legacy HTTP accepted any number here, and persisted data may already contain -// negative values. Keep archive timestamps permissive while other clocks stay non-negative. -export const ArchivedTimestamp = Schema.Number +// Legacy HTTP accepted negative values here. Keep archive timestamps permissive +// while excluding non-finite values that cannot round-trip through JSON. +export const ArchivedTimestamp = Schema.Finite const Time = Schema.Struct({ created: NonNegativeInt, diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 9343326738..a01b7330e2 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -258,6 +258,18 @@ describe("HttpApi server", () => { }) }) + test("matches SDK-affecting request schema details", () => { + const effect = effectOpenApi() + const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody + const sessionUpdateSchema = + typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate + ? sessionUpdate.content?.["application/json"]?.schema + : undefined + const sessionUpdateProperties = sessionUpdateSchema?.properties as Record | undefined + const time = sessionUpdateProperties?.time + expect(time?.properties?.archived).toEqual({ type: "number" }) + }) + test("documents event routes as server-sent events", () => { const effect = effectOpenApi() From bcae852d28d08598bc013c8fbca9cb8522704881 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 1 May 2026 11:12:25 -0400 Subject: [PATCH 005/178] zen: remove hardcoded safety identifier --- .../app/src/routes/zen/util/handler.ts | 29 ++++++++++++++----- .../zen/util/provider/openai-compatible.ts | 3 +- .../src/routes/zen/util/provider/openai.ts | 5 +--- .../src/routes/zen/util/provider/provider.ts | 1 - packages/console/core/src/model.ts | 1 - 5 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index c15197b6e3..2f75668e67 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -141,7 +141,10 @@ export async function handler( ) validateModelSettings(billingSource, authInfo) updateProviderKey(authInfo, providerInfo) - logger.metric({ provider: providerInfo.id }) + logger.metric({ + provider: providerInfo.id, + "provider.model": providerInfo.model, + }) const startTimestamp = Date.now() const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream) @@ -149,12 +152,23 @@ export async function handler( providerInfo.modifyBody({ ...createBodyConverter(opts.format, providerInfo.format)(body), model: providerInfo.model, - ...providerInfo.payloadModifier, - ...Object.fromEntries( - Object.entries(providerInfo.payloadMappings ?? {}) - .map(([k, v]) => [k, input.request.headers.get(v)]) - .filter(([_k, v]) => !!v), - ), + ...(() => { + const replacer = (obj: Record): Record => + Object.fromEntries( + Object.entries(obj).flatMap(([k, v]) => { + if (Array.isArray(v)) return [[k, v]] + if (typeof v === "object") return [[k, replacer(v)]] + if (v === "$ip") return [[k, ip]] + if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] + if (v.startsWith("$header.")) { + const headerValue = input.request.headers.get(v.slice(8)) + return headerValue ? [[k, headerValue]] : [] + } + return [[k, v]] + }), + ) + return replacer(providerInfo.payloadModifier ?? {}) + })(), }), ) logger.debug("REQUEST URL: " + reqUrl) @@ -514,7 +528,6 @@ export async function handler( reqModel, providerModel: modelProvider.model, adjustCacheUsage: providerProps.adjustCacheUsage, - safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined, workspaceID: authInfo?.workspaceID, } if (format === "anthropic") return anthropicHelper(opts) diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index 97b0abc64f..e6dedb1a4b 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -23,7 +23,7 @@ type Usage = { } } -export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({ +export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({ format: "oa-compat", modifyUrl: (providerApi: string) => providerApi + "/chat/completions", modifyHeaders: (headers: Headers, body: Record, apiKey: string) => { @@ -34,7 +34,6 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif return { ...body, ...(body.stream ? { stream_options: { include_usage: true } } : {}), - ...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}), } }, createBinaryStreamDecoder: () => undefined, diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index bee1e01ec0..5d61a903ef 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -18,10 +18,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ modifyHeaders: (headers: Headers, body: Record, apiKey: string) => { headers.set("authorization", `Bearer ${apiKey}`) }, - modifyBody: (body: Record) => ({ - ...body, - ...(workspaceID ? { safety_identifier: workspaceID } : {}), - }), + modifyBody: (body: Record) => body, createBinaryStreamDecoder: () => undefined, streamSeparator: "\n\n", createUsageParser: () => { diff --git a/packages/console/app/src/routes/zen/util/provider/provider.ts b/packages/console/app/src/routes/zen/util/provider/provider.ts index ffb23f54c9..86446bfd85 100644 --- a/packages/console/app/src/routes/zen/util/provider/provider.ts +++ b/packages/console/app/src/routes/zen/util/provider/provider.ts @@ -37,7 +37,6 @@ export type ProviderHelper = (input: { reqModel: string providerModel: string adjustCacheUsage?: boolean - safetyIdentifier?: string workspaceID?: string }) => { format: ZenData.Format diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 6281382d65..dc3febe055 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -40,7 +40,6 @@ export namespace ZenData { disabled: z.boolean().optional(), storeModel: z.string().optional(), payloadModifier: z.record(z.string(), z.any()).optional(), - safetyIdentifier: z.boolean().optional(), }), ), }) From 29ec07700c43c18c7fdfb46a594c1c8e4a1d8524 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 1 May 2026 11:15:17 -0500 Subject: [PATCH 006/178] fix: bedrock reasoning issue (#25303) --- packages/opencode/src/session/message-v2.ts | 10 +++++++++- packages/opencode/test/session/message-v2.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index a017ead1e6..5f97074b20 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -938,10 +938,18 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } if (part.type === "reasoning") { + if (differentModel) { + if (part.text.trim().length > 0) + assistantMessage.parts.push({ + type: "text", + text: part.text, + }) + continue + } assistantMessage.parts.push({ type: "reasoning", text: part.text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), + providerMetadata: part.metadata, }) } } diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 89bae246a7..afd24e7e1b 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -469,6 +469,13 @@ describe("session.message-v2.toModelMessage", () => { }, { ...basePart(assistantID, "a2"), + type: "reasoning", + text: "thinking", + metadata: { openai: { reasoning: "meta" } }, + time: { start: 0 }, + }, + { + ...basePart(assistantID, "a3"), type: "tool", callID: "call-1", tool: "bash", @@ -495,6 +502,7 @@ describe("session.message-v2.toModelMessage", () => { role: "assistant", content: [ { type: "text", text: "done" }, + { type: "text", text: "thinking" }, { type: "tool-call", toolCallId: "call-1", From 2115df57bf40c1f9a2e5d03502852f874fd82b69 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 16:16:45 +0000 Subject: [PATCH 007/178] Update VOUCHED list https://github.com/anomalyco/opencode/issues/25288#issuecomment-4360290197 --- .github/VOUCHED.td | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 8618701ebf..3f9df695aa 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -32,6 +32,7 @@ rekram1-node -ricardo-m-l -robinmordasiewicz rubdos +-saisharan0103 spamming ai prs shantur simonklee -spider-yamet clawdbot/llm psychosis, spam pinging the team From c2609cbf046a35ce0013b41f5b3f72532d972ad4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 30 Apr 2026 23:57:27 -0400 Subject: [PATCH 008/178] core: allow agents to access global tmp directory without permission prompts Agents can now create temporary files in the global tmp directory without triggering external_directory permission prompts. This enables agents to freely use temporary storage for intermediate files during builds and other operations. --- packages/core/test/global.test.ts | 16 ++++++++++++++++ packages/opencode/test/agent/agent.test.ts | 20 +++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/global.test.ts diff --git a/packages/core/test/global.test.ts b/packages/core/test/global.test.ts new file mode 100644 index 0000000000..4e13e88424 --- /dev/null +++ b/packages/core/test/global.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { Global } from "@opencode-ai/core/global" + +describe("global paths", () => { + test("tmp path is under the system temp directory", () => { + expect(Global.Path.tmp).toBe(path.join(os.tmpdir(), "opencode")) + expect(Global.make().tmp).toBe(Global.Path.tmp) + }) + + test("tmp path is created on module load", async () => { + expect((await fs.stat(Global.Path.tmp)).isDirectory()).toBe(true) + }) +}) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index ec384709da..1fc118d0d8 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -5,6 +5,7 @@ import { provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" +import { Global } from "@opencode-ai/core/global" // Helper to evaluate permission for a tool with wildcard pattern function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined { @@ -83,7 +84,7 @@ test("explore agent denies edit and write", async () => { }) }) -test("explore agent asks for external directories and allows Truncate.GLOB", async () => { +test("explore agent asks for external directories and allows whitelisted external paths", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir() await Instance.provide({ @@ -93,6 +94,9 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy expect(explore).toBeDefined() expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") + expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action).toBe( + "allow", + ) }, }) }) @@ -515,6 +519,20 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }) }) +test("global tmp directory children are allowed for external_directory", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const build = await load(tmp.path, (svc) => svc.get("build")) + expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action).toBe( + "allow", + ) + expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") + }, + }) +}) + test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir({ From 6252412d94c91c83bb76f98686f4c987903019e9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 1 May 2026 20:03:10 +0000 Subject: [PATCH 009/178] chore: generate --- packages/opencode/test/agent/agent.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 1fc118d0d8..06bb103f06 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -94,9 +94,9 @@ test("explore agent asks for external directories and allows whitelisted externa expect(explore).toBeDefined() expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow") - expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action).toBe( - "allow", - ) + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "agent-work"), explore!.permission).action, + ).toBe("allow") }, }) }) @@ -525,9 +525,9 @@ test("global tmp directory children are allowed for external_directory", async ( directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) - expect(Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action).toBe( - "allow", - ) + expect( + Permission.evaluate("external_directory", path.join(Global.Path.tmp, "scratch"), build!.permission).action, + ).toBe("allow") expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("ask") }, }) From 478156456e92c3db04803953127b4a4af2db064c Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 1 May 2026 15:49:14 -0500 Subject: [PATCH 010/178] core: fix npm package detection to properly handle cached directories without installed packages (#25354) --- packages/core/src/npm.ts | 8 ++++++-- packages/core/test/npm.test.ts | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 92e4042768..8dac8faf01 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -120,13 +120,17 @@ export const layer = Layer.effect( } })() - if (yield* afs.existsSafe(dir)) { + if (yield* afs.existsSafe(path.join(dir, "node_modules", name))) { return resolveEntryPoint(name, path.join(dir, "node_modules", name)) } const tree = yield* reify({ dir, add: [pkg] }) const first = tree.edgesOut.values().next().value?.to - if (!first) return yield* new InstallFailedError({ add: [pkg], dir }) + if (!first) { + const result = resolveEntryPoint(name, path.join(dir, "node_modules", name)) + if (Option.isSome(result.entrypoint)) return result + return yield* new InstallFailedError({ add: [pkg], dir }) + } return resolveEntryPoint(first.name, first.path) }, Effect.scoped) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 3e94a08692..3d0767aaff 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -1,7 +1,12 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, Layer, Option } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" import { Npm } from "@opencode-ai/core/npm" +import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { tmpdir } from "./fixture/tmpdir" const win = process.platform === "win32" @@ -15,6 +20,14 @@ const writePackage = (dir: string, pkg: Record) => }), ) +const npmLayer = (cache: string) => + Npm.layer.pipe( + Layer.provide(EffectFlock.layer), + Layer.provide(AppFileSystem.layer), + Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })), + Layer.provide(NodeFileSystem.layer), + ) + describe("Npm.sanitize", () => { test("keeps normal scoped package specs unchanged", () => { expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme") @@ -29,6 +42,28 @@ describe("Npm.sanitize", () => { }) }) +describe("Npm.add", () => { + test("reifies when package cache directory exists without the package installed", async () => { + await using tmp = await tmpdir() + await fs.mkdir(path.join(tmp.path, "fixture-provider")) + await writePackage(path.join(tmp.path, "fixture-provider"), { + name: "fixture-provider", + main: "index.js", + }) + await Bun.write(path.join(tmp.path, "fixture-provider", "index.js"), "export const fixture = true\n") + + const spec = `fixture-provider@file:${path.join(tmp.path, "fixture-provider")}` + await fs.mkdir(path.join(tmp.path, "cache", "packages", Npm.sanitize(spec)), { recursive: true }) + + const entry = await Effect.gen(function* () { + const npm = yield* Npm.Service + return yield* npm.add(spec) + }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "cache"))), Effect.runPromise) + + expect(Option.isSome(entry.entrypoint)).toBe(true) + }) +}) + describe("Npm.install", () => { test("respects omit from project .npmrc", async () => { await using tmp = await tmpdir() From 51e310c9ce3faa3dab382222000a001db678cfb3 Mon Sep 17 00:00:00 2001 From: Zeke Sikelianos Date: Fri, 1 May 2026 16:14:22 -0700 Subject: [PATCH 011/178] fix(read): prevent unsupported image formats from being sending to provider (#21114) Co-authored-by: Aiden Cline --- packages/opencode/src/tool/read.ts | 7 +++++-- packages/opencode/test/tool/read.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index ef33a48dea..78436489f5 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -10,7 +10,7 @@ import DESCRIPTION from "./read.txt" import { InstanceState } from "@/effect/instance-state" import { assertExternalDirectoryEffect } from "./external-directory" import { Instruction } from "../session/instruction" -import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media" +import { isPdfAttachment, sniffAttachmentMime } from "@/util/media" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 @@ -18,6 +18,7 @@ const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` const MAX_BYTES = 50 * 1024 const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB` const SAMPLE_BYTES = 4096 +const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]) // `offset` and `limit` were originally `z.coerce.number()` — the runtime // coercion was useful when the tool was called from a shell but serves no @@ -220,7 +221,9 @@ export const ReadTool = Tool.define( const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES) const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath)) - if (isImageAttachment(mime) || isPdfAttachment(mime)) { + const isImage = SUPPORTED_IMAGE_MIMES.has(mime) + + if (isImage || isPdfAttachment(mime)) { const bytes = yield* fs.readFile(filepath) const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully" return { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index db66787549..c20b084372 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -440,6 +440,24 @@ root_type Monster;` expect(result.output).toContain("table Monster") }), ) + + it.live("falls through unsupported image mime types to text", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const cases = [ + ["image.bmp", "BM text content"], + ["photo.tiff", "II text content"], + ["photo.avif", "avif text content"], + ] as const + + for (const item of cases) { + yield* put(path.join(dir, item[0]), item[1]) + const result = yield* exec(dir, { filePath: path.join(dir, item[0]) }) + expect(result.attachments).toBeUndefined() + expect(result.output).toContain(item[1]) + } + }), + ) }) describe("tool.read loaded instructions", () => { From cec9c6122af88ed76264f9e899a26fb250943df3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 22:18:06 -0400 Subject: [PATCH 012/178] Move instance loading into Effect service (#25277) --- .../opencode/src/project/instance-context.ts | 10 + .../opencode/src/project/instance-store.ts | 186 +++++++++++++ packages/opencode/src/project/instance.ts | 147 +--------- .../instance/httpapi/handlers/global.ts | 5 +- .../routes/instance/httpapi/lifecycle.ts | 29 +- .../httpapi/middleware/instance-context.ts | 36 ++- .../server/routes/instance/httpapi/server.ts | 2 + .../opencode/test/project/instance.test.ts | 254 ++++++++++++++++++ .../server/httpapi-instance-context.test.ts | 2 + 9 files changed, 502 insertions(+), 169 deletions(-) create mode 100644 packages/opencode/src/project/instance-context.ts create mode 100644 packages/opencode/src/project/instance-store.ts create mode 100644 packages/opencode/test/project/instance.test.ts diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts new file mode 100644 index 0000000000..22ceb28b33 --- /dev/null +++ b/packages/opencode/src/project/instance-context.ts @@ -0,0 +1,10 @@ +import { LocalContext } from "@/util/local-context" +import type * as Project from "./project" + +export interface InstanceContext { + directory: string + worktree: string + project: Project.Info +} + +export const context = LocalContext.create("instance") diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts new file mode 100644 index 0000000000..327835ea07 --- /dev/null +++ b/packages/opencode/src/project/instance-store.ts @@ -0,0 +1,186 @@ +import { GlobalBus } from "@/bus/global" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { disposeInstance } from "@/effect/instance-registry" +import { makeRuntime } from "@/effect/run-service" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" +import { context, type InstanceContext } from "./instance-context" +import * as Project from "./project" + +export interface LoadInput { + directory: string + init?: () => Promise + worktree?: string + project?: Project.Info +} + +export interface Interface { + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect + readonly dispose: (ctx: InstanceContext) => Effect.Effect + readonly disposeAll: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceStore") {} + +interface Entry { + readonly deferred: Deferred.Deferred +} + +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const project = yield* Project.Service + const scope = yield* Scope.Scope + const cache = new Map() + + const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) { + const ctx = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : yield* project.fromDirectory(input.directory).pipe( + Effect.map((result) => ({ + directory: input.directory, + worktree: result.sandbox, + project: result.project, + })), + ) + const init = input.init + if (init) yield* Effect.promise(() => context.provide(ctx, init)) + return ctx + }) + + const removeEntry = (directory: string, entry: Entry) => + Effect.sync(() => { + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true + }) + + const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) { + const exit = yield* Effect.exit(boot({ ...input, directory })) + if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) + yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) + }) + + const emitDisposed = (input: { directory: string; project?: string }) => + Effect.sync(() => + GlobalBus.emit("event", { + directory: input.directory, + project: input.project, + workspace: WorkspaceContext.workspaceID, + payload: { + type: "server.instance.disposed", + properties: { + directory: input.directory, + }, + }, + }), + ) + + const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) { + yield* Effect.logInfo("disposing instance", { directory: ctx.directory }) + yield* Effect.promise(() => disposeInstance(ctx.directory)) + yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id }) + }) + + const disposeEntry = Effect.fnUntraced(function* (directory: string, entry: Entry, ctx: InstanceContext) { + if (cache.get(directory) !== entry) return false + yield* disposeContext(ctx) + if (cache.get(directory) !== entry) return false + cache.delete(directory) + return true + }) + + const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) { + const directory = AppFileSystem.resolve(input.directory) + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const existing = cache.get(directory) + if (existing) return yield* restore(Deferred.await(existing.deferred)) + + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("creating instance", { directory }) + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ) + }) + + const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) { + const directory = AppFileSystem.resolve(input.directory) + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const previous = cache.get(directory) + const entry: Entry = { deferred: Deferred.makeUnsafe() } + cache.set(directory, entry) + yield* Effect.gen(function* () { + yield* Effect.logInfo("reloading instance", { directory }) + if (previous) { + yield* Deferred.await(previous.deferred).pipe(Effect.ignore) + yield* Effect.promise(() => disposeInstance(directory)) + yield* emitDisposed({ directory, project: input.project?.id }) + } + yield* completeLoad(directory, input, entry) + }).pipe(Effect.forkIn(scope, { startImmediately: true })) + return yield* restore(Deferred.await(entry.deferred)) + }), + ) + }) + + const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { + const entry = cache.get(ctx.directory) + if (!entry) return yield* disposeContext(ctx) + + const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) return yield* removeEntry(ctx.directory, entry).pipe(Effect.asVoid) + if (exit.value !== ctx) return + yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid) + }) + + const disposeAllOnce = Effect.fnUntraced(function* () { + yield* Effect.logInfo("disposing all instances") + yield* Effect.forEach( + [...cache.entries()], + (item) => + Effect.gen(function* () { + const exit = yield* Deferred.await(item[1].deferred).pipe(Effect.exit) + if (Exit.isFailure(exit)) { + yield* Effect.logWarning("instance dispose failed", { key: item[0], cause: exit.cause }) + yield* removeEntry(item[0], item[1]) + return + } + yield* disposeEntry(item[0], item[1], exit.value) + }), + { discard: true }, + ) + }) + + const cachedDisposeAll = yield* Effect.cachedWithTTL(disposeAllOnce(), Duration.zero) + const disposeAll = Effect.fn("InstanceStore.disposeAll")(function* () { + return yield* cachedDisposeAll + }) + + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) + + return Service.of({ + load, + reload, + dispose, + disposeAll, + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer)) + +export const runtime = makeRuntime(Service, defaultLayer) + +export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 623e886231..69cb74fd6d 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,77 +1,20 @@ -import { GlobalBus } from "@/bus/global" -import { disposeInstance } from "@/effect/instance-registry" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { iife } from "@/util/iife" -import * as Log from "@opencode-ai/core/util/log" -import { LocalContext } from "@/util/local-context" import * as Project from "./project" -import { WorkspaceContext } from "@/control-plane/workspace-context" +import { context, type InstanceContext } from "./instance-context" +import { InstanceStore } from "./instance-store" -export interface InstanceContext { - directory: string - worktree: string - project: Project.Info -} - -const context = LocalContext.create("instance") -const cache = new Map>() -const project = makeRuntime(Project.Service, Project.defaultLayer) - -const disposal = { - all: undefined as Promise | undefined, -} - -function boot(input: { directory: string; init?: () => Promise; worktree?: string; project?: Project.Info }) { - return iife(async () => { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : await project - .runPromise((svc) => svc.fromDirectory(input.directory)) - .then(({ project, sandbox }) => ({ - directory: input.directory, - worktree: sandbox, - project, - })) - await context.provide(ctx, async () => { - await input.init?.() - }) - return ctx - }) -} - -function track(directory: string, next: Promise) { - const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) - throw error - }) - cache.set(directory, task) - return task -} +export type { InstanceContext } from "./instance-context" +export type { LoadInput } from "./instance-store" export const Instance = { + load(input: InstanceStore.LoadInput): Promise { + return InstanceStore.runtime.runPromise((store) => store.load(input)) + }, async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = AppFileSystem.resolve(input.directory) - let existing = cache.get(directory) - if (!existing) { - Log.Default.info("creating instance", { directory }) - existing = track( - directory, - boot({ - directory, - init: input.init, - }), - ) - } - const ctx = await existing - return context.provide(ctx, async () => { - return input.fn() - }) + return context.provide( + await Instance.load({ directory: input.directory, init: input.init }), + async () => input.fn(), + ) }, get current() { return context.use() @@ -117,74 +60,12 @@ export const Instance = { return context.provide(ctx, fn) }, async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = AppFileSystem.resolve(input.directory) - Log.Default.info("reloading instance", { directory }) - await disposeInstance(directory) - cache.delete(directory) - const next = track(directory, boot({ ...input, directory })) - - GlobalBus.emit("event", { - directory, - project: input.project?.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) - - return await next + return InstanceStore.runtime.runPromise((store) => store.reload(input)) }, async dispose() { - const directory = Instance.directory - const project = Instance.project - Log.Default.info("disposing instance", { directory }) - await disposeInstance(directory) - cache.delete(directory) - - GlobalBus.emit("event", { - directory, - project: project.id, - workspace: WorkspaceContext.workspaceID, - payload: { - type: "server.instance.disposed", - properties: { - directory, - }, - }, - }) + return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) }, async disposeAll() { - if (disposal.all) return disposal.all - - disposal.all = iife(async () => { - Log.Default.info("disposing all instances") - const entries = [...cache.entries()] - for (const [key, value] of entries) { - if (cache.get(key) !== value) continue - - const ctx = await value.catch((error) => { - Log.Default.warn("instance dispose failed", { key, error }) - return undefined - }) - - if (!ctx) { - if (cache.get(key) === value) cache.delete(key) - continue - } - - if (cache.get(key) !== value) continue - - await context.provide(ctx, async () => { - await Instance.dispose() - }) - } - }).finally(() => { - disposal.all = undefined - }) - - return disposal.all + return InstanceStore.runtime.runPromise((store) => store.disposeAll()) }, } diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index cd1bebec47..bcad2832e2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,7 +1,7 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" import { Installation } from "@/installation" -import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { Effect, Queue, Schema } from "effect" @@ -68,6 +68,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl Effect.gen(function* () { const config = yield* Config.Service const installation = yield* Installation.Service + const store = yield* InstanceStore.Service const health = Effect.fn("GlobalHttpApi.health")(function* () { return { healthy: true as const, version: InstallationVersion } @@ -86,7 +87,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl }) const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* Effect.promise(() => Instance.disposeAll()) + yield* store.disposeAll() GlobalBus.emit("event", { directory: "global", payload: { type: "global.disposed", properties: {} }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts index 7b263980c5..53d54e2a81 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/lifecycle.ts @@ -1,13 +1,13 @@ -import type { WorkspaceID } from "@/control-plane/schema" -import { WorkspaceContext } from "@/control-plane/workspace-context" -import { WorkspaceRef } from "@/effect/instance-ref" -import { Instance, type InstanceContext } from "@/project/instance" +import { EffectBridge } from "@/effect/bridge" +import type { InstanceContext } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Effect } from "effect" import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http" type MarkedInstance = { ctx: InstanceContext - workspaceID?: WorkspaceID + store: InstanceStore.Interface + bridge: EffectBridge.Shape } // Disposal is requested by an endpoint handler, but must run from the outer @@ -17,20 +17,9 @@ const disposeAfterResponse = new WeakMap() const mark = (ctx: InstanceContext) => Effect.gen(function* () { - return { ctx, workspaceID: yield* WorkspaceRef } + return { ctx, store: yield* InstanceStore.Service, bridge: yield* EffectBridge.make() } }) -// Instance.dispose/reload still publish events through legacy ALS helpers. -// Effect request handlers carry these values in services, so bridge them back -// into the legacy contexts only around the lifecycle operation. -const restoreMarked = (marked: MarkedInstance, fn: () => A) => - Effect.promise(() => - WorkspaceContext.provide({ - workspaceID: marked.workspaceID, - fn: () => Instance.restore(marked.ctx, fn), - }), - ) - export const markInstanceForDisposal = (ctx: InstanceContext) => Effect.gen(function* () { const marked = yield* mark(ctx) @@ -43,11 +32,11 @@ export const markInstanceForDisposal = (ctx: InstanceContext) => ) }) -export const markInstanceForReload = (ctx: InstanceContext, next: Parameters[0]) => +export const markInstanceForReload = (ctx: InstanceContext, next: InstanceStore.LoadInput) => Effect.gen(function* () { const marked = yield* mark(ctx) return yield* HttpEffect.appendPreResponseHandler((_request, response) => - Effect.as(Effect.uninterruptible(restoreMarked(marked, () => Instance.reload(next))), response), + Effect.as(Effect.uninterruptible(marked.bridge.run(marked.store.reload(next))), response), ) }) @@ -58,6 +47,6 @@ export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) => const marked = disposeAfterResponse.get(request.source) if (!marked) return response disposeAfterResponse.delete(request.source) - yield* Effect.uninterruptible(restoreMarked(marked, () => Instance.dispose())) + yield* Effect.uninterruptible(marked.bridge.run(marked.store.dispose(marked.ctx))) return response }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index c80f1caeb6..1d7d84cbc0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,9 +1,8 @@ import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" import { AppRuntime } from "@/effect/app-runtime" import { InstanceBootstrap } from "@/project/bootstrap" -import { Instance } from "@/project/instance" import type { InstanceContext } from "@/project/instance" -import { Filesystem } from "@/util/filesystem" +import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" @@ -24,22 +23,23 @@ function decode(input: string): string { } } -function makeInstanceContext(directory: string): Effect.Effect { - return Effect.promise(() => - Instance.provide({ - directory: Filesystem.resolve(decode(directory)), - init: () => AppRuntime.runPromise(InstanceBootstrap), - fn: () => Instance.current, - }), - ) +function makeInstanceContext( + store: InstanceStore.Interface, + directory: string, +): Effect.Effect { + return store.load({ + directory: decode(directory), + init: () => AppRuntime.runPromise(InstanceBootstrap), + }) } function provideInstanceContext( effect: Effect.Effect, + store: InstanceStore.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext - const ctx = yield* makeInstanceContext(route.directory) + const ctx = yield* makeInstanceContext(store, route.directory) return yield* effect.pipe( Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, route.workspaceID), @@ -47,9 +47,17 @@ function provideInstanceContext( }) } -export const instanceContextLayer = Layer.succeed( +export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, - InstanceContextMiddleware.of((effect) => provideInstanceContext(effect)), + Effect.gen(function* () { + const store = yield* InstanceStore.Service + return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) + }), ) -export const instanceRouterMiddleware = HttpRouter.middleware()((effect) => provideInstanceContext(effect)) +export const instanceRouterMiddleware = HttpRouter.middleware()( + Effect.gen(function* () { + const store = yield* InstanceStore.Service + return (effect) => provideInstanceContext(effect, store) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index e6dedfe2c4..783f84ec82 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -17,6 +17,7 @@ import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" import { Provider } from "@/provider/provider" @@ -145,6 +146,7 @@ export function createRoutes(corsOptions?: CorsOptions) { Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, + InstanceStore.defaultLayer, MCP.defaultLayer, Permission.defaultLayer, Project.defaultLayer, diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts new file mode 100644 index 0000000000..f9fb6dca4e --- /dev/null +++ b/packages/opencode/test/project/instance.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Fiber, Layer } from "effect" +import { registerDisposer } from "../../src/effect/instance-registry" +import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("InstanceStore", () => { + it.live("loads instance context without installing ALS for the caller", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const ctx = yield* store.load({ directory: dir }) + + expect(ctx.directory).toBe(dir) + expect(ctx.worktree).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("runs load init inside the loaded legacy instance context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let initializedDirectory: string | undefined + + yield* store.load({ + directory: dir, + init: async () => { + initializedDirectory = Instance.directory + }, + }) + + expect(initializedDirectory).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) + + it.live("caches loaded instance context by directory", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let initialized = 0 + + const first = yield* store.load({ + directory: dir, + init: async () => { + initialized++ + }, + }) + const second = yield* store.load({ + directory: dir, + init: async () => { + initialized++ + }, + }) + + expect(second).toBe(first) + expect(initialized).toBe(1) + }), + ) + + it.live("dedupes concurrent loads while init is in flight", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let initialized = 0 + + const first = yield* store + .load({ + directory: dir, + init: async () => { + initialized++ + started.resolve() + await release.promise + }, + }) + .pipe(Effect.forkScoped) + + yield* Effect.promise(() => started.promise) + + const second = yield* store + .load({ + directory: dir, + init: async () => { + initialized++ + }, + }) + .pipe(Effect.forkScoped) + + expect(initialized).toBe(1) + release.resolve() + + const [firstCtx, secondCtx] = yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + expect(secondCtx).toBe(firstCtx) + expect(initialized).toBe(1) + }), + ) + + it.live("removes failed loads from the cache", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + let attempts = 0 + + const failed = yield* store + .load({ + directory: dir, + init: async () => { + attempts++ + throw new Error("init failed") + }, + }) + .pipe( + Effect.as(false), + Effect.catchCause(() => Effect.succeed(true)), + ) + + expect(failed).toBe(true) + + const ctx = yield* store.load({ + directory: dir, + init: async () => { + attempts++ + }, + }) + + expect(ctx.directory).toBe(dir) + expect(attempts).toBe(2) + }), + ) + + it.live("reload replaces the cached context", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + + const first = yield* store.load({ directory: dir }) + const second = yield* store.reload({ directory: dir }) + const cached = yield* store.load({ directory: dir }) + + expect(second).not.toBe(first) + expect(cached).toBe(second) + }), + ) + + it.live("stale dispose does not delete an in-flight reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const reloading = Promise.withResolvers() + const releaseReload = Promise.withResolvers() + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + const first = yield* store.load({ directory: dir }) + const reload = yield* store + .reload({ + directory: dir, + init: async () => { + reloading.resolve() + await releaseReload.promise + }, + }) + .pipe(Effect.forkScoped) + + yield* Effect.promise(() => reloading.promise) + const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped) + releaseReload.resolve() + + const second = yield* Fiber.join(reload) + yield* Fiber.join(staleDispose) + + expect(disposed).toEqual([dir]) + expect(yield* store.load({ directory: dir })).toBe(second) + }), + ) + + it.live("dedupes concurrent disposeAll calls", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const disposing = Promise.withResolvers() + const releaseDispose = Promise.withResolvers() + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + disposing.resolve() + await releaseDispose.promise + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + yield* store.load({ directory: dir }) + const first = yield* store.disposeAll().pipe(Effect.forkScoped) + yield* Effect.promise(() => disposing.promise) + const second = yield* store.disposeAll().pipe(Effect.forkScoped) + + expect(disposed).toEqual([dir]) + releaseDispose.resolve() + yield* Effect.all([Fiber.join(first), Fiber.join(second)]) + expect(disposed).toEqual([dir]) + }), + ) + + it.live("re-arms disposeAll after completion", () => + Effect.gen(function* () { + const dir1 = yield* tmpdirScoped({ git: true }) + const dir2 = yield* tmpdirScoped({ git: true }) + const store = yield* InstanceStore.Service + const disposed: Array = [] + const off = registerDisposer(async (directory) => { + disposed.push(directory) + }) + yield* Effect.addFinalizer(() => Effect.sync(off)) + + yield* store.load({ directory: dir1 }) + yield* store.disposeAll() + expect(disposed).toEqual([dir1]) + + yield* store.load({ directory: dir2 }) + yield* store.disposeAll() + expect(disposed).toEqual([dir1, dir2]) + }), + ) + + it.live("keeps Instance.provide as the legacy ALS wrapper", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + + const directory = yield* Effect.promise(() => + Instance.provide({ + directory: dir, + fn: () => Instance.directory, + }), + ) + + expect(directory).toBe(dir) + expect(() => Instance.current).toThrow() + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 9dea20dd66..6098ad9aaf 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -12,6 +12,7 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -40,6 +41,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, + InstanceStore.defaultLayer, Project.defaultLayer, Workspace.defaultLayer, ), From 0b498dd4483a408dc2e142bde3d7c6173cd824db Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 22:18:52 -0400 Subject: [PATCH 013/178] fix(httpapi): preserve OpenAPI parameter parity (#25291) --- .../src/server/routes/instance/AGENTS.md | 8 +++ .../server/routes/instance/httpapi/public.ts | 54 ++++++++++++++----- .../test/server/httpapi-bridge.test.ts | 18 ++++++- 3 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/src/server/routes/instance/AGENTS.md diff --git a/packages/opencode/src/server/routes/instance/AGENTS.md b/packages/opencode/src/server/routes/instance/AGENTS.md new file mode 100644 index 0000000000..c94fa64af7 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/AGENTS.md @@ -0,0 +1,8 @@ +# Instance Route Parity + +This directory contains the legacy Hono instance routes and the experimental Effect HttpApi implementation under `httpapi/`. Keep them behaviorally aligned. + +- When adding, removing, or changing a legacy Hono route, update the matching Effect HttpApi group and handler in `httpapi/` in the same change unless the route is intentionally unsupported. +- When changing an Effect HttpApi route, verify the legacy Hono route has the same public behavior, request shape, response shape, status codes, and instance/workspace routing semantics. +- Keep OpenAPI/SDK-visible schemas aligned. If a difference is only an OpenAPI generation artifact, prefer fixing the source schema first; use `httpapi/public.ts` normalization only for compatibility shims that cannot be represented cleanly in the source schema. +- Add or update parity coverage in `test/server/httpapi-bridge.test.ts` or the focused HttpApi tests when behavior or schema parity could regress. diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 17d6e0d063..c9668336ae 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -39,6 +39,7 @@ type OpenApiSchema = { maximum?: number minimum?: number oneOf?: OpenApiSchema[] + pattern?: string prefixItems?: OpenApiSchema[] properties?: Record required?: string[] @@ -74,9 +75,18 @@ const QueryNumberParameters = new Set(["start", "cursor", "limit", "method"]) const QueryBooleanParameters = new Set(["roots", "archived"]) const QueryParameterSchemas = { "GET /find/file limit": { type: "integer", minimum: 1, maximum: 200 }, + "GET /session/{sessionID}/diff messageID": { type: "string", pattern: "^msg.*" }, "GET /session/{sessionID}/message limit": { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, } satisfies Record +const PathParameterSchemas = { + sessionID: { type: "string", pattern: "^ses.*" }, + messageID: { type: "string", pattern: "^msg.*" }, + partID: { type: "string", pattern: "^prt.*" }, + permissionID: { type: "string", pattern: "^per.*" }, + ptyID: { type: "string", pattern: "^pty.*" }, +} satisfies Record + const LegacyComponentDescriptions = { LogLevel: "Log level", ServerConfig: "Server configuration for opencode serve and web commands", @@ -428,6 +438,11 @@ function fixSelfReferencingComponents(spec: OpenApiSpec) { /** Strip `{type:"null"}` arms that Effect's `Schema.optional` adds to OpenAPI unions. */ function stripOptionalNull(schema: OpenApiSchema): OpenApiSchema { + if (schema.allOf?.length === 1) { + const [constraint] = schema.allOf + delete schema.allOf + return stripOptionalNull({ ...schema, ...constraint }) + } if (isEmptyObjectUnion(schema)) return { type: "object", properties: {} } const options = flattenOptions(schema.anyOf ?? schema.oneOf) if (options) { @@ -476,25 +491,40 @@ function flattenOptions(options: OpenApiSchema[] | undefined): OpenApiSchema[] | } function normalizeParameter(param: OpenApiParameter, route: string) { - if (param.in !== "query" || !param.schema || typeof param.schema !== "object") return - const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] - if (override) { - param.schema = override + if (!param.schema || typeof param.schema !== "object") return + if (param.in === "path") { + param.schema = pathParameterSchema(route, param.name) ?? stripOptionalNull(param.schema) return } - if (QueryNumberParameters.has(param.name)) { - param.schema = { type: "number" } - return - } - if (QueryBooleanParameters.has(param.name)) { - param.schema = { - anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + if (param.in === "query") { + const override = QueryParameterSchemas[`${route} ${param.name}` as keyof typeof QueryParameterSchemas] + if (override) { + param.schema = override + return + } + if (QueryNumberParameters.has(param.name)) { + param.schema = { type: "number" } + return + } + if (QueryBooleanParameters.has(param.name)) { + param.schema = { + anyOf: [{ type: "boolean" }, { type: "string", enum: ["true", "false"] }], + } + return } - return } param.schema = stripOptionalNull(param.schema) } +function pathParameterSchema(route: string, name: string) { + if (name in PathParameterSchemas) return PathParameterSchemas[name as keyof typeof PathParameterSchemas] + if (name === "id" && route.startsWith("DELETE /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } + if (name === "id" && route.startsWith("POST /experimental/workspace/")) return { type: "string", pattern: "^wrk.*" } + if (name === "requestID" && route.startsWith("POST /permission/")) return { type: "string", pattern: "^per.*" } + if (name === "requestID" && route.startsWith("POST /question/")) return { type: "string", pattern: "^que.*" } + return undefined +} + export const PublicApi = OpenCodeHttpApi.annotateMerge( OpenApi.annotations({ title: "opencode", diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index a01b7330e2..2b8a62cc5f 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -119,7 +119,23 @@ type RequestBody = { function parameterKey(param: unknown): string | undefined { if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined if (typeof param.in !== "string" || typeof param.name !== "string") return undefined - return `${param.in}:${param.name}:${"required" in param && param.required === true}` + return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema( + "schema" in param ? param.schema : undefined, + )}` +} + +function stableSchema(input: unknown): string { + return JSON.stringify(sortSchema(input)) +} + +function sortSchema(input: unknown): unknown { + if (Array.isArray(input)) return input.map(sortSchema) + if (!input || typeof input !== "object") return input + return Object.fromEntries( + Object.entries(input) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => [key, sortSchema(value)]), + ) } function parameterSchema(input: { From d297c29f2276f8e0d4389c8af38b5aad504d3ee1 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 02:19:48 +0000 Subject: [PATCH 014/178] chore: generate --- packages/opencode/src/project/instance.ts | 5 ++--- .../routes/instance/httpapi/middleware/instance-context.ts | 5 +---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 69cb74fd6d..aa4f48c56e 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -11,9 +11,8 @@ export const Instance = { return InstanceStore.runtime.runPromise((store) => store.load(input)) }, async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - return context.provide( - await Instance.load({ directory: input.directory, init: input.init }), - async () => input.fn(), + return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => + input.fn(), ) }, get current() { diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index 1d7d84cbc0..4bb15cd3cd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -23,10 +23,7 @@ function decode(input: string): string { } } -function makeInstanceContext( - store: InstanceStore.Interface, - directory: string, -): Effect.Effect { +function makeInstanceContext(store: InstanceStore.Interface, directory: string): Effect.Effect { return store.load({ directory: decode(directory), init: () => AppRuntime.runPromise(InstanceBootstrap), From 160928a9a9ca41e09e907a6001a7041f5dee681b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 22:42:03 -0400 Subject: [PATCH 015/178] Extract InstanceStore.provide helper (#25372) --- packages/opencode/src/project/instance-store.ts | 6 ++++++ .../httpapi/middleware/instance-context.ts | 17 ++++------------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 327835ea07..7abb0bb7e3 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -1,5 +1,6 @@ import { GlobalBus } from "@/bus/global" import { WorkspaceContext } from "@/control-plane/workspace-context" +import { InstanceRef } from "@/effect/instance-ref" import { disposeInstance } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -19,6 +20,7 @@ export interface Interface { readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeAll: () => Effect.Effect + readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect } export class Service extends Context.Service()("@opencode/InstanceStore") {} @@ -168,6 +170,9 @@ export const layer: Layer.Layer = Layer.effect( return yield* cachedDisposeAll }) + const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => + load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) return Service.of({ @@ -175,6 +180,7 @@ export const layer: Layer.Layer = Layer.effect( reload, dispose, disposeAll, + provide, }) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index 4bb15cd3cd..bf0093bd2b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,7 +1,6 @@ -import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { WorkspaceRef } from "@/effect/instance-ref" import { AppRuntime } from "@/effect/app-runtime" import { InstanceBootstrap } from "@/project/bootstrap" -import type { InstanceContext } from "@/project/instance" import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -23,23 +22,15 @@ function decode(input: string): string { } } -function makeInstanceContext(store: InstanceStore.Interface, directory: string): Effect.Effect { - return store.load({ - directory: decode(directory), - init: () => AppRuntime.runPromise(InstanceBootstrap), - }) -} - function provideInstanceContext( effect: Effect.Effect, store: InstanceStore.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext - const ctx = yield* makeInstanceContext(store, route.directory) - return yield* effect.pipe( - Effect.provideService(InstanceRef, ctx), - Effect.provideService(WorkspaceRef, route.workspaceID), + return yield* store.provide( + { directory: decode(route.directory), init: () => AppRuntime.runPromise(InstanceBootstrap) }, + effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), ) }) } From 15719330965567b31129cda0b6618a7af2924f9a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 1 May 2026 23:06:22 -0400 Subject: [PATCH 016/178] Drop ALS fallbacks from containsPath and workspace routing (#25374) --- packages/opencode/src/config/config.ts | 3 +-- packages/opencode/src/project/instance.ts | 11 ++++---- .../httpapi/middleware/workspace-routing.ts | 11 +------- .../opencode/test/file/path-traversal.test.ts | 26 +++++++++---------- 4 files changed, 20 insertions(+), 31 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 44841fe6fc..bfc3567bf5 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -23,7 +23,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { InstanceRef } from "@/effect/instance-ref" import { zod } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" @@ -459,7 +458,7 @@ export const layer = Layer.effect( const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source === "OPENCODE_CONFIG_CONTENT") return "local" - if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" + if (Instance.containsPath(source, ctx)) return "local" return "global" }) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index aa4f48c56e..af7672872c 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -30,16 +30,15 @@ export const Instance = { /** * Check if a path is within the project boundary. - * Returns true if path is inside Instance.directory OR Instance.worktree. + * Returns true if path is inside ctx.directory OR ctx.worktree. * Paths within the worktree but outside the working directory should not trigger external_directory permission. */ - containsPath(filepath: string, ctx?: InstanceContext) { - const instance = ctx ?? Instance - if (AppFileSystem.contains(instance.directory, filepath)) return true + containsPath(filepath: string, ctx: InstanceContext) { + if (AppFileSystem.contains(ctx.directory, filepath)) return true // Non-git projects set worktree to "/" which would match ANY absolute path. // Skip worktree check in this case to preserve external_directory permissions. - if (instance.worktree === "/") return false - return AppFileSystem.contains(instance.worktree, filepath) + if (ctx.worktree === "/") return false + return AppFileSystem.contains(ctx.worktree, filepath) }, /** * Captures the current instance ALS context and returns a wrapper that diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index f38c91ccec..c8762bae66 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -2,7 +2,6 @@ import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import type { Target } from "@/control-plane/types" import { Workspace } from "@/control-plane/workspace" -import { Instance } from "@/project/instance" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" import * as Fence from "@/server/fence" @@ -43,14 +42,6 @@ export class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service< } >()("@opencode/ExperimentalHttpApiWorkspaceRouting") {} -function currentDirectory(): string { - try { - return Instance.directory - } catch { - return process.cwd() - } -} - function requestURL(request: HttpServerRequest.HttpServerRequest): URL { return new URL(request.url, "http://localhost") } @@ -65,7 +56,7 @@ function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): Worksp } function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string { - return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || currentDirectory() + return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || process.cwd() } function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, url: URL): boolean { diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index a52af7023a..2d306f60ba 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -128,8 +128,8 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "foo.txt"))).toBe(true) - expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"))).toBe(true) + expect(Instance.containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) + expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) }, }) }) @@ -143,11 +143,11 @@ describe("Instance.containsPath", () => { directory: subdir, fn: () => { // .opencode at worktree root, but we're running from packages/lib - expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"))).toBe(true) + expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) // sibling package should also be accessible - expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"))).toBe(true) + expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) // worktree root itself - expect(Instance.containsPath(tmp.path)).toBe(true) + expect(Instance.containsPath(tmp.path, Instance.current)).toBe(true) }, }) }) @@ -158,8 +158,8 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath("/etc/passwd")).toBe(false) - expect(Instance.containsPath("/tmp/other-project")).toBe(false) + expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(Instance.containsPath("/tmp/other-project", Instance.current)).toBe(false) }, }) }) @@ -170,7 +170,7 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"))).toBe(false) + expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) }, }) }) @@ -182,8 +182,8 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { expect(Instance.directory).toBe(Instance.worktree) - expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true) - expect(Instance.containsPath("/etc/passwd")).toBe(false) + expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) }, }) }) @@ -195,9 +195,9 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { // worktree is "/" for non-git projects, but containsPath should NOT allow all paths - expect(Instance.containsPath(path.join(tmp.path, "file.txt"))).toBe(true) - expect(Instance.containsPath("/etc/passwd")).toBe(false) - expect(Instance.containsPath("/tmp/other")).toBe(false) + expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(Instance.containsPath("/tmp/other", Instance.current)).toBe(false) }, }) }) From f33aec1139afa5e9741cc19e9f2d1b60558d4861 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 00:02:52 -0400 Subject: [PATCH 017/178] Convert LoadInput.init to Effect + extract InstanceBootstrap as a Service (#25376) --- packages/opencode/src/cli/bootstrap.ts | 2 - packages/opencode/src/cli/cmd/tui/worker.ts | 2 - packages/opencode/src/config/config.ts | 3 +- packages/opencode/src/file/index.ts | 6 +- packages/opencode/src/lsp/lsp.ts | 9 +- packages/opencode/src/project/bootstrap.ts | 94 +++++++++++++------ .../opencode/src/project/instance-context.ts | 14 +++ .../opencode/src/project/instance-store.ts | 87 +++++++++-------- packages/opencode/src/project/instance.ts | 53 +++++++---- .../instance/httpapi/handlers/project.ts | 2 - .../httpapi/middleware/instance-context.ts | 10 +- .../server/routes/instance/httpapi/server.ts | 10 ++ .../src/server/routes/instance/middleware.ts | 2 - .../src/server/routes/instance/project.ts | 2 - packages/opencode/src/server/workspace.ts | 2 - packages/opencode/src/tool/bash.ts | 6 +- .../opencode/src/tool/external-directory.ts | 4 +- packages/opencode/src/worktree/index.ts | 2 - .../opencode/test/file/path-traversal.test.ts | 29 +++--- .../opencode/test/project/instance.test.ts | 37 ++++---- .../server/httpapi-instance-context.test.ts | 2 + 21 files changed, 224 insertions(+), 154 deletions(-) diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 2604e703ea..3190fda62f 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,11 +1,9 @@ import { AppRuntime } from "@/effect/app-runtime" -import { InstanceBootstrap } from "../project/bootstrap" import { Instance } from "../project/instance" export async function bootstrap(directory: string, cb: () => Promise) { return Instance.provide({ directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), fn: async () => { try { const result = await cb() diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index adb7453a72..8b62c5038b 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -2,7 +2,6 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" @@ -77,7 +76,6 @@ export const rpc = { async checkUpgrade(input: { directory: string }) { await Instance.provide({ directory: input.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), fn: async () => { await upgrade().catch(() => {}) }, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bfc3567bf5..9e9a6e3810 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -23,6 +23,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { containsPath } from "../project/instance-context" import { zod } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" @@ -458,7 +459,7 @@ export const layer = Layer.effect( const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source === "OPENCODE_CONFIG_CONTENT") return "local" - if (Instance.containsPath(source, ctx)) return "local" + if (containsPath(source, ctx)) return "local" return "global" }) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 4a474881cb..4dd6a3ae7a 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -10,7 +10,7 @@ import fuzzysort from "fuzzysort" import ignore from "ignore" import path from "path" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../project/instance" +import { containsPath } from "../project/instance-context" import * as Log from "@opencode-ai/core/util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" @@ -507,7 +507,7 @@ export const layer = Layer.effect( const ctx = yield* InstanceState.context const full = path.join(ctx.directory, file) - if (!Instance.containsPath(full, ctx)) { + if (!containsPath(full, ctx)) { throw new Error("Access denied: path escapes project directory") } @@ -587,7 +587,7 @@ export const layer = Layer.effect( } const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory - if (!Instance.containsPath(resolved, ctx)) { + if (!containsPath(resolved, ctx)) { throw new Error("Access denied: path escapes project directory") } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 5fcff772ec..5110eccbf8 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -12,7 +12,7 @@ import { Process } from "@/util/process" import { spawn as lspspawn } from "./launch" import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { containsPath } from "@/project/instance-context" import { NonNegativeInt, withStatics } from "@/util/schema" import { zod, ZodOverride } from "@/util/effect-zod" @@ -221,12 +221,7 @@ export const layer = Layer.effect( const getClients = Effect.fnUntraced(function* (file: string) { const ctx = yield* InstanceState.context - if ( - !AppFileSystem.contains(ctx.directory, file) && - (ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file)) - ) { - return [] as LSPClient.Info[] - } + if (!containsPath(file, ctx)) return [] as LSPClient.Info[] const s = yield* InstanceState.get(state) return yield* Effect.promise(async () => { const extension = path.parse(file).ext || file diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index ae52ac5503..9f77de2d4d 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -8,37 +8,71 @@ import * as Vcs from "./vcs" import { Bus } from "../bus" import { Command } from "../command" import { InstanceState } from "@/effect/instance-state" -import * as Log from "@opencode-ai/core/util/log" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" -import * as Effect from "effect/Effect" +import { Context, Effect, Layer } from "effect" import { Config } from "@/config/config" -export const InstanceBootstrap = Effect.gen(function* () { - const ctx = yield* InstanceState.context - Log.Default.info("bootstrapping", { directory: ctx.directory }) - // everything depends on config so eager load it for nice traces - yield* Config.Service.use((svc) => svc.get()) - // Plugin can mutate config so it has to be initialized before anything else. - yield* Plugin.Service.use((svc) => svc.init()) - yield* Effect.all( - [ - LSP.Service, - ShareNext.Service, - Format.Service, - File.Service, - FileWatcher.Service, - Vcs.Service, - Snapshot.Service, - ].map((s) => Effect.forkDetach(s.use((i) => i.init()))), - ).pipe(Effect.withSpan("InstanceBootstrap.init")) - - const projectID = ctx.project.id - yield* Bus.Service.use((svc) => - svc.subscribeCallback(Command.Event.Executed, async (payload) => { - if (payload.properties.name === Command.Default.INIT) { - Project.setInitialized(projectID) - } - }), - ) -}).pipe(Effect.withSpan("InstanceBootstrap")) +export interface Interface { + readonly run: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + // Yield each bootstrap dep at layer init so `run` itself has R = never. + // This breaks the circular declaration loop through Config → Instance → InstanceStore + // (instance-store.ts only yields this Service tag, never the impl-side services). + const bus = yield* Bus.Service + const config = yield* Config.Service + const file = yield* File.Service + const fileWatcher = yield* FileWatcher.Service + const format = yield* Format.Service + const lsp = yield* LSP.Service + const plugin = yield* Plugin.Service + const shareNext = yield* ShareNext.Service + const snapshot = yield* Snapshot.Service + const vcs = yield* Vcs.Service + + const run = Effect.gen(function* () { + const ctx = yield* InstanceState.context + yield* Effect.logInfo("bootstrapping", { directory: ctx.directory }) + // everything depends on config so eager load it for nice traces + yield* config.get() + // Plugin can mutate config so it has to be initialized before anything else. + yield* plugin.init() + yield* Effect.all( + [lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())), + ).pipe(Effect.withSpan("InstanceBootstrap.init")) + + const projectID = ctx.project.id + yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => { + if (payload.properties.name === Command.Default.INIT) { + Project.setInitialized(projectID) + } + }) + }).pipe(Effect.withSpan("InstanceBootstrap")) + + return Service.of({ run }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide([ + Bus.layer, + Config.defaultLayer, + File.defaultLayer, + FileWatcher.defaultLayer, + Format.defaultLayer, + LSP.defaultLayer, + Plugin.defaultLayer, + Project.defaultLayer, + ShareNext.defaultLayer, + Snapshot.defaultLayer, + Vcs.defaultLayer, + ]), +) + +export * as InstanceBootstrap from "./bootstrap" diff --git a/packages/opencode/src/project/instance-context.ts b/packages/opencode/src/project/instance-context.ts index 22ceb28b33..b281f492d4 100644 --- a/packages/opencode/src/project/instance-context.ts +++ b/packages/opencode/src/project/instance-context.ts @@ -1,4 +1,5 @@ import { LocalContext } from "@/util/local-context" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import type * as Project from "./project" export interface InstanceContext { @@ -8,3 +9,16 @@ export interface InstanceContext { } export const context = LocalContext.create("instance") + +/** + * Check if a path is within the project boundary. + * Returns true if path is inside ctx.directory OR ctx.worktree. + * Paths within the worktree but outside the working directory should not trigger external_directory permission. + */ +export function containsPath(filepath: string, ctx: InstanceContext): boolean { + if (AppFileSystem.contains(ctx.directory, filepath)) return true + // Non-git projects set worktree to "/" which would match ANY absolute path. + // Skip worktree check in this case to preserve external_directory permissions. + if (ctx.worktree === "/") return false + return AppFileSystem.contains(ctx.worktree, filepath) +} diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 7abb0bb7e3..74df60ada7 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -5,22 +5,29 @@ import { disposeInstance } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" -import { context, type InstanceContext } from "./instance-context" +import { type InstanceContext } from "./instance-context" import * as Project from "./project" -export interface LoadInput { +export interface LoadInput { directory: string - init?: () => Promise + /** + * Additional setup to run after the default InstanceBootstrap. + * Mainly used by tests for env-var setup or file writes that need the instance ALS context. + */ + init?: Effect.Effect worktree?: string project?: Project.Info } export interface Interface { - readonly load: (input: LoadInput) => Effect.Effect - readonly reload: (input: LoadInput) => Effect.Effect + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeAll: () => Effect.Effect - readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect + readonly provide: ( + input: LoadInput, + effect: Effect.Effect, + ) => Effect.Effect } export class Service extends Context.Service()("@opencode/InstanceStore") {} @@ -36,25 +43,25 @@ export const layer: Layer.Layer = Layer.effect( const scope = yield* Scope.Scope const cache = new Map() - const boot = Effect.fn("InstanceStore.boot")(function* (input: LoadInput & { directory: string }) { - const ctx = - input.project && input.worktree - ? { - directory: input.directory, - worktree: input.worktree, - project: input.project, - } - : yield* project.fromDirectory(input.directory).pipe( - Effect.map((result) => ({ + const boot = (input: LoadInput & { directory: string }) => + Effect.gen(function* () { + const ctx: InstanceContext = + input.project && input.worktree + ? { directory: input.directory, - worktree: result.sandbox, - project: result.project, - })), - ) - const init = input.init - if (init) yield* Effect.promise(() => context.provide(ctx, init)) - return ctx - }) + worktree: input.worktree, + project: input.project, + } + : yield* project.fromDirectory(input.directory).pipe( + Effect.map((result) => ({ + directory: input.directory, + worktree: result.sandbox, + project: result.project, + })), + ) + if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx)) + return ctx + }).pipe(Effect.withSpan("InstanceStore.boot")) const removeEntry = (directory: string, entry: Entry) => Effect.sync(() => { @@ -63,11 +70,12 @@ export const layer: Layer.Layer = Layer.effect( return true }) - const completeLoad = Effect.fnUntraced(function* (directory: string, input: LoadInput, entry: Entry) { - const exit = yield* Effect.exit(boot({ ...input, directory })) - if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) - yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) - }) + const completeLoad = (directory: string, input: LoadInput, entry: Entry) => + Effect.gen(function* () { + const exit = yield* Effect.exit(boot({ ...input, directory })) + if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) + yield* Deferred.done(entry.deferred, exit).pipe(Effect.asVoid) + }) const emitDisposed = (input: { directory: string; project?: string }) => Effect.sync(() => @@ -98,9 +106,9 @@ export const layer: Layer.Layer = Layer.effect( return true }) - const load = Effect.fn("InstanceStore.load")(function* (input: LoadInput) { + const load = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) - return yield* Effect.uninterruptibleMask((restore) => + return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const existing = cache.get(directory) if (existing) return yield* restore(Deferred.await(existing.deferred)) @@ -113,12 +121,12 @@ export const layer: Layer.Layer = Layer.effect( }).pipe(Effect.forkIn(scope, { startImmediately: true })) return yield* restore(Deferred.await(entry.deferred)) }), - ) - }) + ).pipe(Effect.withSpan("InstanceStore.load")) + } - const reload = Effect.fn("InstanceStore.reload")(function* (input: LoadInput) { + const reload = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) - return yield* Effect.uninterruptibleMask((restore) => + return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { const previous = cache.get(directory) const entry: Entry = { deferred: Deferred.makeUnsafe() } @@ -134,8 +142,8 @@ export const layer: Layer.Layer = Layer.effect( }).pipe(Effect.forkIn(scope, { startImmediately: true })) return yield* restore(Deferred.await(entry.deferred)) }), - ) - }) + ).pipe(Effect.withSpan("InstanceStore.reload")) + } const dispose = Effect.fn("InstanceStore.dispose")(function* (ctx: InstanceContext) { const entry = cache.get(ctx.directory) @@ -170,7 +178,10 @@ export const layer: Layer.Layer = Layer.effect( return yield* cachedDisposeAll }) - const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => + const provide = ( + input: LoadInput, + effect: Effect.Effect, + ): Effect.Effect => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index af7672872c..549df4b751 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,4 +1,5 @@ -import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect } from "effect" +import { InstanceRef } from "@/effect/instance-ref" import * as Project from "./project" import { context, type InstanceContext } from "./instance-context" import { InstanceStore } from "./instance-store" @@ -6,13 +7,37 @@ import { InstanceStore } from "./instance-store" export type { InstanceContext } from "./instance-context" export type { LoadInput } from "./instance-store" +type LegacyLoadInput = { + directory: string + init?: () => Promise + project?: Project.Info + worktree?: string +} + +// Promise-style legacy inits often read Instance.directory etc. from the ALS context. +// The new Effect-typed init path doesn't bind ALS — it provides InstanceRef. To keep +// legacy inits working without forcing every test to convert, bind ALS around the +// Promise call here using the instance ctx that the store provides via InstanceRef. +const liftLegacyInput = (input: LegacyLoadInput): InstanceStore.LoadInput => { + const { init, ...rest } = input + if (!init) return rest + return { + ...rest, + init: Effect.gen(function* () { + const ctx = yield* InstanceRef + yield* Effect.promise(() => (ctx ? context.provide(ctx, init) : init())) + }), + } +} + export const Instance = { - load(input: InstanceStore.LoadInput): Promise { - return InstanceStore.runtime.runPromise((store) => store.load(input)) + load(input: LegacyLoadInput): Promise { + return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input))) }, - async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => - input.fn(), + async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { + return context.provide( + await Instance.load({ directory: input.directory, init: input.init }), + async () => input.fn(), ) }, get current() { @@ -28,18 +53,6 @@ export const Instance = { return context.use().project }, - /** - * Check if a path is within the project boundary. - * Returns true if path is inside ctx.directory OR ctx.worktree. - * Paths within the worktree but outside the working directory should not trigger external_directory permission. - */ - containsPath(filepath: string, ctx: InstanceContext) { - if (AppFileSystem.contains(ctx.directory, filepath)) return true - // Non-git projects set worktree to "/" which would match ANY absolute path. - // Skip worktree check in this case to preserve external_directory permissions. - if (ctx.worktree === "/") return false - return AppFileSystem.contains(ctx.worktree, filepath) - }, /** * Captures the current instance ALS context and returns a wrapper that * restores it when called. Use this for callbacks that fire outside the @@ -57,8 +70,8 @@ export const Instance = { restore(ctx: InstanceContext, fn: () => R): R { return context.provide(ctx, fn) }, - async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - return InstanceStore.runtime.runPromise((store) => store.reload(input)) + async reload(input: LegacyLoadInput) { + return InstanceStore.runtime.runPromise((store) => store.reload(liftLegacyInput(input))) }, async dispose() { return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts index ae2761ac32..3c1dd350db 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/project.ts @@ -1,6 +1,5 @@ import { AppRuntime } from "@/effect/app-runtime" import * as InstanceState from "@/effect/instance-state" -import { InstanceBootstrap } from "@/project/bootstrap" import { Project } from "@/project/project" import { ProjectID } from "@/project/schema" import { Effect } from "effect" @@ -29,7 +28,6 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project", directory: ctx.directory, worktree: ctx.directory, project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), }) return next }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index bf0093bd2b..0e82da31b3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,5 +1,4 @@ import { WorkspaceRef } from "@/effect/instance-ref" -import { AppRuntime } from "@/effect/app-runtime" import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" @@ -25,11 +24,12 @@ function decode(input: string): string { function provideInstanceContext( effect: Effect.Effect, store: InstanceStore.Interface, + bootstrap: InstanceBootstrap.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext return yield* store.provide( - { directory: decode(route.directory), init: () => AppRuntime.runPromise(InstanceBootstrap) }, + { directory: decode(route.directory), init: bootstrap.run }, effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), ) }) @@ -39,13 +39,15 @@ export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, Effect.gen(function* () { const store = yield* InstanceStore.Service - return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) + const bootstrap = yield* InstanceBootstrap.Service + return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap)) }), ) export const instanceRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { const store = yield* InstanceStore.Service - return (effect) => provideInstanceContext(effect, store) + const bootstrap = yield* InstanceBootstrap.Service + return (effect) => provideInstanceContext(effect, store, bootstrap) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 783f84ec82..3ac0298c6b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -11,13 +11,16 @@ import { Config } from "@/config/config" import { Command } from "@/command" import * as Observability from "@opencode-ai/core/effect/observability" import { File } from "@/file" +import { FileWatcher } from "@/file/watcher" import { Ripgrep } from "@/file/ripgrep" import { Format } from "@/format" import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" +import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" import { Provider } from "@/provider/provider" @@ -32,7 +35,9 @@ import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { SessionShare } from "@/share/session" +import { ShareNext } from "@/share/share-next" import { Skill } from "@/skill" +import { Snapshot } from "@/snapshot" import { SyncEvent } from "@/sync" import { ToolRegistry } from "@/tool/registry" import { lazy } from "@/util/lazy" @@ -143,12 +148,15 @@ export function createRoutes(corsOptions?: CorsOptions) { Command.defaultLayer, Config.defaultLayer, File.defaultLayer, + FileWatcher.defaultLayer, Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, + InstanceBootstrap.defaultLayer, InstanceStore.defaultLayer, MCP.defaultLayer, Permission.defaultLayer, + Plugin.defaultLayer, Project.defaultLayer, ProviderAuth.defaultLayer, Provider.defaultLayer, @@ -163,6 +171,8 @@ export function createRoutes(corsOptions?: CorsOptions) { SessionRunState.defaultLayer, SessionStatus.defaultLayer, SessionSummary.defaultLayer, + ShareNext.defaultLayer, + Snapshot.defaultLayer, SyncEvent.defaultLayer, Skill.defaultLayer, Todo.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index 19918b8b48..622d6296f0 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,6 +1,5 @@ import type { MiddlewareHandler } from "hono" import { Instance } from "@/project/instance" -import { InstanceBootstrap } from "@/project/bootstrap" import { AppRuntime } from "@/effect/app-runtime" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -24,7 +23,6 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler async fn() { return Instance.provide({ directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), async fn() { return next() }, diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index b9f86b1839..14c8c87b09 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -7,7 +7,6 @@ import z from "zod" import { ProjectID } from "@/project/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { InstanceBootstrap } from "@/project/bootstrap" import { AppRuntime } from "@/effect/app-runtime" import { jsonRequest, runRequest } from "./trace" @@ -86,7 +85,6 @@ export const ProjectRoutes = lazy(() => directory: dir, worktree: dir, project: next, - init: () => AppRuntime.runPromise(InstanceBootstrap), }) return c.json(next) }, diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index f757137483..06930d07ca 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -5,7 +5,6 @@ import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" -import { InstanceBootstrap } from "@/project/bootstrap" import { Instance } from "@/project/instance" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" @@ -100,7 +99,6 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware fn: () => Instance.provide({ directory: target.directory, - init: () => AppRuntime.runPromise(InstanceBootstrap), async fn() { return next() }, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index fe3e45d66f..bf00082505 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -6,7 +6,7 @@ import * as Tool from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" import * as Log from "@opencode-ai/core/util/log" -import { Instance, type InstanceContext } from "../project/instance" +import { containsPath, type InstanceContext } from "../project/instance-context" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" @@ -386,7 +386,7 @@ export const BashTool = Tool.define( for (const arg of pathArgs(command, ps)) { const resolved = yield* argPath(arg, cwd, ps, shell) log.info("resolved path", { arg, resolved }) - if (!resolved || Instance.containsPath(resolved, instance)) continue + if (!resolved || containsPath(resolved, instance)) continue const dir = (yield* fs.isDir(resolved)) ? resolved : path.dirname(resolved) scan.dirs.add(dir) } @@ -612,7 +612,7 @@ export const BashTool = Tool.define( Effect.sync(() => tree.delete()), ) const scan = yield* collect(tree.rootNode, cwd, ps, shell, executeInstance) - if (!Instance.containsPath(cwd, executeInstance)) scan.dirs.add(cwd) + if (!containsPath(cwd, executeInstance)) scan.dirs.add(cwd) yield* ask(ctx, scan) }), ) diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 0dd9a1af30..23d416b53e 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import * as EffectLogger from "@opencode-ai/core/effect/logger" import { InstanceState } from "@/effect/instance-state" import type * as Tool from "./tool" -import { Instance } from "../project/instance" +import { containsPath } from "../project/instance-context" import { AppFileSystem } from "@opencode-ai/core/filesystem" type Kind = "file" | "directory" @@ -24,7 +24,7 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec const ins = yield* InstanceState.context const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target - if (Instance.containsPath(full, ins)) return + if (containsPath(full, ins)) return const kind = options?.kind ?? "file" const dir = kind === "directory" ? full : path.dirname(full) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 6a7ccb9614..2e9b6736f5 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -2,7 +2,6 @@ import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" import { Instance } from "../project/instance" -import { InstanceBootstrap } from "../project/bootstrap" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -255,7 +254,6 @@ export const layer: Layer.Layer< const booted = yield* Effect.promise(() => Instance.provide({ directory: info.directory, - init: () => BootstrapRuntime.runPromise(InstanceBootstrap), fn: () => undefined, }) .then(() => true) diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 2d306f60ba..3a5ce2323e 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { containsPath } from "../../src/project/instance-context" import { provideInstance, tmpdir } from "../fixture/fixture" const run = (eff: Effect.Effect) => @@ -121,15 +122,15 @@ describe("File.list path traversal protection", () => { }) }) -describe("Instance.containsPath", () => { +describe("containsPath", () => { test("returns true for path inside directory", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) - expect(Instance.containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, "src", "file.ts"), Instance.current)).toBe(true) }, }) }) @@ -143,11 +144,11 @@ describe("Instance.containsPath", () => { directory: subdir, fn: () => { // .opencode at worktree root, but we're running from packages/lib - expect(Instance.containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, ".opencode", "state"), Instance.current)).toBe(true) // sibling package should also be accessible - expect(Instance.containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) + expect(containsPath(path.join(tmp.path, "packages", "other", "file.ts"), Instance.current)).toBe(true) // worktree root itself - expect(Instance.containsPath(tmp.path, Instance.current)).toBe(true) + expect(containsPath(tmp.path, Instance.current)).toBe(true) }, }) }) @@ -158,8 +159,8 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) - expect(Instance.containsPath("/tmp/other-project", Instance.current)).toBe(false) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath("/tmp/other-project", Instance.current)).toBe(false) }, }) }) @@ -170,7 +171,7 @@ describe("Instance.containsPath", () => { await Instance.provide({ directory: tmp.path, fn: () => { - expect(Instance.containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) + expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) }, }) }) @@ -182,8 +183,8 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { expect(Instance.directory).toBe(Instance.worktree) - expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) - expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) }, }) }) @@ -195,9 +196,9 @@ describe("Instance.containsPath", () => { directory: tmp.path, fn: () => { // worktree is "/" for non-git projects, but containsPath should NOT allow all paths - expect(Instance.containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) - expect(Instance.containsPath("/etc/passwd", Instance.current)).toBe(false) - expect(Instance.containsPath("/tmp/other", Instance.current)).toBe(false) + expect(containsPath(path.join(tmp.path, "file.txt"), Instance.current)).toBe(true) + expect(containsPath("/etc/passwd", Instance.current)).toBe(false) + expect(containsPath("/tmp/other", Instance.current)).toBe(false) }, }) }) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index f9fb6dca4e..2e3da29a7a 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Fiber, Layer } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" @@ -26,7 +27,7 @@ describe("InstanceStore", () => { }), ) - it.live("runs load init inside the loaded legacy instance context", () => + it.live("runs load init with InstanceRef provided", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service @@ -34,9 +35,9 @@ describe("InstanceStore", () => { yield* store.load({ directory: dir, - init: async () => { - initializedDirectory = Instance.directory - }, + init: Effect.gen(function* () { + initializedDirectory = (yield* InstanceRef)?.directory + }), }) expect(initializedDirectory).toBe(dir) @@ -52,15 +53,15 @@ describe("InstanceStore", () => { const first = yield* store.load({ directory: dir, - init: async () => { + init: Effect.sync(() => { initialized++ - }, + }), }) const second = yield* store.load({ directory: dir, - init: async () => { + init: Effect.sync(() => { initialized++ - }, + }), }) expect(second).toBe(first) @@ -79,11 +80,11 @@ describe("InstanceStore", () => { const first = yield* store .load({ directory: dir, - init: async () => { + init: Effect.promise(async () => { initialized++ started.resolve() await release.promise - }, + }), }) .pipe(Effect.forkScoped) @@ -92,9 +93,9 @@ describe("InstanceStore", () => { const second = yield* store .load({ directory: dir, - init: async () => { + init: Effect.sync(() => { initialized++ - }, + }), }) .pipe(Effect.forkScoped) @@ -116,10 +117,10 @@ describe("InstanceStore", () => { const failed = yield* store .load({ directory: dir, - init: async () => { + init: Effect.sync(() => { attempts++ throw new Error("init failed") - }, + }), }) .pipe( Effect.as(false), @@ -130,9 +131,9 @@ describe("InstanceStore", () => { const ctx = yield* store.load({ directory: dir, - init: async () => { + init: Effect.sync(() => { attempts++ - }, + }), }) expect(ctx.directory).toBe(dir) @@ -170,10 +171,10 @@ describe("InstanceStore", () => { const reload = yield* store .reload({ directory: dir, - init: async () => { + init: Effect.promise(async () => { reloading.resolve() await releaseReload.promise - }, + }), }) .pipe(Effect.forkScoped) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 6098ad9aaf..15d3facd30 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -11,6 +11,7 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" @@ -41,6 +42,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, + InstanceBootstrap.defaultLayer, InstanceStore.defaultLayer, Project.defaultLayer, Workspace.defaultLayer, From becf57ee6a704e62d7075630852e9c17dfbb110a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 04:03:59 +0000 Subject: [PATCH 018/178] chore: generate --- .../opencode/src/project/instance-store.ts | 5 +- packages/opencode/src/project/instance.ts | 5 +- packages/sdk/js/src/v2/gen/types.gen.ts | 128 +++---- packages/sdk/openapi.json | 360 +++++++++--------- 4 files changed, 247 insertions(+), 251 deletions(-) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 74df60ada7..e96c421a76 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -178,10 +178,7 @@ export const layer: Layer.Layer = Layer.effect( return yield* cachedDisposeAll }) - const provide = ( - input: LoadInput, - effect: Effect.Effect, - ): Effect.Effect => + const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 549df4b751..5c7f102633 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -35,9 +35,8 @@ export const Instance = { return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input))) }, async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - return context.provide( - await Instance.load({ directory: input.directory, init: input.init }), - async () => input.fn(), + return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => + input.fn(), ) }, get current() { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b925ec6096..e46f8e04f0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -33,13 +33,6 @@ export type EventProjectUpdated = { properties: Project } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string - } -} - export type EventServerConnected = { type: "server.connected" properties: { @@ -54,18 +47,10 @@ export type EventGlobalDisposed = { } } -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" +export type EventServerInstanceDisposed = { + type: "server.instance.disposed" properties: { - file: string - event: "add" | "change" | "unlink" + directory: string } } @@ -230,6 +215,53 @@ export type EventInstallationUpdateAvailable = { } } +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -452,38 +484,6 @@ export type EventVcsBranchUpdated = { } } -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - export type EventWorktreeReady = { type: "worktree.ready" properties: { @@ -1112,11 +1112,9 @@ export type GlobalEvent = { workspace?: string payload: | EventProjectUpdated - | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed - | EventFileEdited - | EventFileWatcherUpdated + | EventServerInstanceDisposed | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -1126,6 +1124,12 @@ export type GlobalEvent = { | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventFileEdited + | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -1141,10 +1145,6 @@ export type GlobalEvent = { | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated @@ -2055,11 +2055,9 @@ export type File = { export type Event = | EventProjectUpdated - | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed - | EventFileEdited - | EventFileWatcherUpdated + | EventServerInstanceDisposed | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -2069,6 +2067,12 @@ export type Event = | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventFileEdited + | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -2084,10 +2088,6 @@ export type Event = | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index cfd8277a3b..930fd8c92a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7674,25 +7674,6 @@ }, "required": ["type", "properties"] }, - "Event.server.instance.disposed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "server.instance.disposed" - }, - "properties": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"] - } - }, - "required": ["type", "properties"] - }, "Event.server.connected": { "type": "object", "properties": { @@ -7721,44 +7702,21 @@ }, "required": ["type", "properties"] }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { + "Event.server.instance.disposed": { "type": "object", "properties": { "type": { "type": "string", - "const": "file.watcher.updated" + "const": "server.instance.disposed" }, "properties": { "type": "object", "properties": { - "file": { + "directory": { "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] } }, - "required": ["file", "event"] + "required": ["directory"] } }, "required": ["type", "properties"] @@ -8225,6 +8183,144 @@ }, "required": ["type", "properties"] }, + "Event.workspace.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "QuestionOption": { "type": "object", "properties": { @@ -8743,102 +8839,6 @@ }, "required": ["type", "properties"] }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, "Event.worktree.ready": { "type": "object", "properties": { @@ -10960,9 +10960,6 @@ { "$ref": "#/components/schemas/Event.project.updated" }, - { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -10970,10 +10967,7 @@ "$ref": "#/components/schemas/Event.global.disposed" }, { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" + "$ref": "#/components/schemas/Event.server.instance.disposed" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -11002,6 +10996,24 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -11047,18 +11059,6 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, { "$ref": "#/components/schemas/Event.worktree.ready" }, @@ -13253,9 +13253,6 @@ { "$ref": "#/components/schemas/Event.project.updated" }, - { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -13263,10 +13260,7 @@ "$ref": "#/components/schemas/Event.global.disposed" }, { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" + "$ref": "#/components/schemas/Event.server.instance.disposed" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -13295,6 +13289,24 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -13340,18 +13352,6 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, { "$ref": "#/components/schemas/Event.worktree.ready" }, From d99dde6306882799d105439473e2ec07803fc8a5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 09:07:59 -0400 Subject: [PATCH 019/178] Migrate test inits from Promise to Effect (#25377) --- packages/opencode/src/effect/run-service.ts | 25 ++- packages/opencode/src/project/instance.ts | 40 +--- .../opencode/test/effect/run-service.test.ts | 40 ++++ .../opencode/test/project/instance.test.ts | 18 ++ .../test/provider/amazon-bedrock.test.ts | 40 ++-- .../opencode/test/provider/provider.test.ts | 196 +++++++++--------- 6 files changed, 199 insertions(+), 160 deletions(-) diff --git a/packages/opencode/src/effect/run-service.ts b/packages/opencode/src/effect/run-service.ts index 28f1068c36..1f3802e80c 100644 --- a/packages/opencode/src/effect/run-service.ts +++ b/packages/opencode/src/effect/run-service.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ManagedRuntime } from "effect" +import { Effect, Fiber, Layer, ManagedRuntime } from "effect" import * as Context from "effect/Context" import { Instance } from "@/project/instance" import { LocalContext } from "@/util/local-context" @@ -24,15 +24,20 @@ export function attachWith(effect: Effect.Effect, refs: Refs): } export function attach(effect: Effect.Effect): Effect.Effect { - try { - return attachWith(effect, { - instance: Instance.current, - workspace: WorkspaceContext.workspaceID, - }) - } catch (err) { - if (!(err instanceof LocalContext.NotFound)) throw err - } - return effect + const workspace = WorkspaceContext.workspaceID + const instance = (() => { + try { + return Instance.current + } catch (err) { + if (!(err instanceof LocalContext.NotFound)) throw err + } + })() + if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace }) + const fiber = Fiber.getCurrent() + return attachWith(effect, { + instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined), + workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined), + }) } export function makeRuntime(service: Context.Service, layer: Layer.Layer) { diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 5c7f102633..662b61bb0f 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,42 +1,18 @@ import { Effect } from "effect" -import { InstanceRef } from "@/effect/instance-ref" -import * as Project from "./project" import { context, type InstanceContext } from "./instance-context" import { InstanceStore } from "./instance-store" export type { InstanceContext } from "./instance-context" export type { LoadInput } from "./instance-store" -type LegacyLoadInput = { - directory: string - init?: () => Promise - project?: Project.Info - worktree?: string -} - -// Promise-style legacy inits often read Instance.directory etc. from the ALS context. -// The new Effect-typed init path doesn't bind ALS — it provides InstanceRef. To keep -// legacy inits working without forcing every test to convert, bind ALS around the -// Promise call here using the instance ctx that the store provides via InstanceRef. -const liftLegacyInput = (input: LegacyLoadInput): InstanceStore.LoadInput => { - const { init, ...rest } = input - if (!init) return rest - return { - ...rest, - init: Effect.gen(function* () { - const ctx = yield* InstanceRef - yield* Effect.promise(() => (ctx ? context.provide(ctx, init) : init())) - }), - } -} - export const Instance = { - load(input: LegacyLoadInput): Promise { - return InstanceStore.runtime.runPromise((store) => store.load(liftLegacyInput(input))) + load(input: InstanceStore.LoadInput): Promise { + return InstanceStore.runtime.runPromise((store) => store.load(input)) }, - async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => - input.fn(), + async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { + return context.provide( + await Instance.load({ directory: input.directory, init: input.init }), + async () => input.fn(), ) }, get current() { @@ -69,8 +45,8 @@ export const Instance = { restore(ctx: InstanceContext, fn: () => R): R { return context.provide(ctx, fn) }, - async reload(input: LegacyLoadInput) { - return InstanceStore.runtime.runPromise((store) => store.reload(liftLegacyInput(input))) + async reload(input: InstanceStore.LoadInput) { + return InstanceStore.runtime.runPromise((store) => store.reload(input)) }, async dispose() { return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) diff --git a/packages/opencode/test/effect/run-service.test.ts b/packages/opencode/test/effect/run-service.test.ts index e9baf88538..16538bb8ae 100644 --- a/packages/opencode/test/effect/run-service.test.ts +++ b/packages/opencode/test/effect/run-service.test.ts @@ -1,9 +1,12 @@ import { expect } from "bun:test" import { Effect, Layer, Context } from "effect" +import { InstanceRef } from "../../src/effect/instance-ref" import { makeRuntime } from "../../src/effect/run-service" +import { ProjectID } from "../../src/project/schema" import { it } from "../lib/effect" class Shared extends Context.Service()("@test/Shared") {} +const testDirectory = "/tmp/opencode-test" it.live("makeRuntime shares dependent layers through the shared memo map", () => Effect.gen(function* () { @@ -47,3 +50,40 @@ it.live("makeRuntime shares dependent layers through the shared memo map", () => expect(n).toBe(1) }), ) + +it.live("makeRuntime inherits InstanceRef from the current fiber", () => + Effect.gen(function* () { + class NeedsInstance extends Context.Service< + NeedsInstance, + { readonly directory: () => Effect.Effect } + >()("@test/NeedsInstance") {} + + const runtime = makeRuntime( + NeedsInstance, + Layer.succeed( + NeedsInstance, + NeedsInstance.of({ + directory: () => + Effect.gen(function* () { + return (yield* InstanceRef)?.directory + }), + }), + ), + ) + + const actual = yield* Effect.promise(() => runtime.runPromise((svc) => svc.directory())) + + expect(actual).toBe(testDirectory) + }).pipe( + Effect.provideService(InstanceRef, { + directory: testDirectory, + worktree: testDirectory, + project: { + id: ProjectID.global, + worktree: testDirectory, + time: { created: 0, updated: 0 }, + sandboxes: [], + }, + }), + ), +) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 2e3da29a7a..b223bf91db 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -252,4 +252,22 @@ describe("InstanceStore", () => { expect(() => Instance.current).toThrow() }), ) + + it.live("does not install legacy ALS around Effect init", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + + const directory = yield* Effect.promise(() => + Instance.provide({ + directory: dir, + init: Effect.sync(() => { + expect(() => Instance.current).toThrow() + }), + fn: () => Instance.directory, + }), + ) + + expect(directory).toBe(dir) + }), + ) }) diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 88c67aa6dc..43b23dafad 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -45,10 +45,10 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_REGION", "us-east-1") set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -70,10 +70,10 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_REGION", "eu-west-1") set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -125,11 +125,11 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") set("AWS_BEARER_TOKEN_BEDROCK", "") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -171,10 +171,10 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") set("AWS_ACCESS_KEY_ID", "test-key-id") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -203,9 +203,9 @@ test("Bedrock: includes custom endpoint in options when specified", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -236,12 +236,12 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -279,9 +279,9 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -316,9 +316,9 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -352,9 +352,9 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() @@ -388,9 +388,9 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("AWS_PROFILE", "default") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index d0aa299e70..edbf4d6648 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -82,9 +82,9 @@ test("provider loaded from env variable", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -137,9 +137,9 @@ test("disabled_providers excludes provider", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() @@ -161,10 +161,10 @@ test("enabled_providers restricts to only listed providers", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -191,9 +191,9 @@ test("model whitelist filters models for provider", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -222,9 +222,9 @@ test("model blacklist excludes specific models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -257,9 +257,9 @@ test("custom model alias via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -394,9 +394,9 @@ test("env variable takes precedence, config merges options", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "env-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -420,9 +420,9 @@ test("getModel returns model for valid provider/model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() @@ -447,9 +447,9 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() }, @@ -500,9 +500,9 @@ test("defaultModel returns first available model when no config set", async () = }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await defaultModel() expect(model.providerID).toBeDefined() @@ -525,9 +525,9 @@ test("defaultModel respects config model setting", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await defaultModel() expect(String(model.providerID)).toBe("anthropic") @@ -640,9 +640,9 @@ test("model options are merged from existing model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -669,9 +669,9 @@ test("provider removed when all models filtered out", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() @@ -692,9 +692,9 @@ test("closest finds model by partial match", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const result = await closest(ProviderID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() @@ -747,9 +747,9 @@ test("getModel uses realIdByKey for aliased models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() @@ -862,9 +862,9 @@ test("model inherits properties from existing database model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -890,9 +890,9 @@ test("disabled_providers prevents loading even with env var", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.openai]).toBeUndefined() @@ -914,10 +914,10 @@ test("enabled_providers with empty array allows no providers", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(Object.keys(providers).length).toBe(0) @@ -944,9 +944,9 @@ test("whitelist and blacklist can be combined", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -1053,9 +1053,9 @@ test("getSmallModel returns appropriate small model", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() @@ -1078,9 +1078,9 @@ test("getSmallModel respects config small_model override", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() @@ -1126,10 +1126,10 @@ test("multiple providers can be configured simultaneously", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() @@ -1205,9 +1205,9 @@ test("model alias name defaults to alias key when id differs", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") @@ -1245,9 +1245,9 @@ test("provider with multiple env var options only includes apiKey when single en }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("MULTI_ENV_KEY_1", "test-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("multi-env")]).toBeDefined() @@ -1287,9 +1287,9 @@ test("provider with single env var includes apiKey automatically", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("SINGLE_ENV_KEY", "my-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("single-env")]).toBeDefined() @@ -1324,9 +1324,9 @@ test("model cost overrides existing cost values", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -1403,11 +1403,11 @@ test("disabled_providers and enabled_providers interaction", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-anthropic") set("OPENAI_API_KEY", "test-openai") set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // anthropic: in enabled, not in disabled = allowed @@ -1561,10 +1561,10 @@ test("provider env fallback - second env var used if first missing", async () => }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { // Only set fallback, not primary set("FALLBACK_KEY", "fallback-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // Provider should load because fallback env var is set @@ -1586,9 +1586,9 @@ test("getModel returns consistent results", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) @@ -1647,9 +1647,9 @@ test("ModelNotFoundError includes suggestions for typos", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { try { await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet @@ -1675,9 +1675,9 @@ test("ModelNotFoundError for provider includes suggestions", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { try { await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic @@ -1723,9 +1723,9 @@ test("getProvider returns provider info", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const provider = await getProvider(ProviderID.anthropic) expect(provider).toBeDefined() @@ -1747,9 +1747,9 @@ test("closest returns undefined when no partial match found", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() @@ -1770,9 +1770,9 @@ test("closest checks multiple query terms in order", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { // First term won't match, second will const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) @@ -1842,9 +1842,9 @@ test("provider options are deeply merged", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // Custom options should be merged @@ -1880,9 +1880,9 @@ test("custom model inherits npm package from models.dev provider config", async }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENAI_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.openai].models["my-custom-model"] @@ -1915,9 +1915,9 @@ test("custom model inherits api.url from models.dev provider", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENROUTER_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.openrouter]).toBeDefined() @@ -2048,9 +2048,9 @@ test("model variants are generated for reasoning models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() // Claude sonnet 4 has reasoning capability @@ -2086,9 +2086,9 @@ test("model variants can be disabled via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2129,9 +2129,9 @@ test("model variants can be customized via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2168,9 +2168,9 @@ test("disabled key is stripped from variant config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2206,9 +2206,9 @@ test("all variants can be disabled via config", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2244,9 +2244,9 @@ test("variant config merges with generated variants", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2282,9 +2282,9 @@ test("variants filtered in second pass for database models", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("OPENAI_API_KEY", "test-api-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.openai].models["gpt-5"] @@ -2386,9 +2386,9 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() @@ -2431,9 +2431,9 @@ test("Google Vertex: supports OpenAI compatible models", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] @@ -2457,11 +2457,11 @@ test("cloudflare-ai-gateway loads with env variables", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() @@ -2489,11 +2489,11 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { }) await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() @@ -2592,10 +2592,10 @@ test("plugin config enabled and disabled providers are honored", async () => { await Instance.provide({ directory: tmp.path, - init: async () => { + init: Effect.promise(async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }, + }).pipe(Effect.asVoid), fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() From a849812e9f2ea3089cea45673ec10ecc80d93136 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 13:09:14 +0000 Subject: [PATCH 020/178] chore: generate --- packages/opencode/src/project/instance.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 662b61bb0f..afc07ad26c 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -10,9 +10,8 @@ export const Instance = { return InstanceStore.runtime.runPromise((store) => store.load(input)) }, async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { - return context.provide( - await Instance.load({ directory: input.directory, init: input.init }), - async () => input.fn(), + return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => + input.fn(), ) }, get current() { From 075f876e6fbaf3e02223e1add69d8b8e2901d5af Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 09:35:39 -0400 Subject: [PATCH 021/178] fix(httpapi): re-land workspace create payload accepts missing extra (#25412) --- .../instance/httpapi/groups/workspace.ts | 5 +- .../instance/httpapi/handlers/workspace.ts | 1 + .../test/server/httpapi-workspace.test.ts | 53 ++++++++++++++++++- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 112b8a3298..08e9e044bb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -9,7 +9,10 @@ import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" -export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) +export const CreatePayload = Schema.Struct({ + ...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]), + extra: Schema.optional(Workspace.CreateInput.fields.extra), +}) export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"])) export const SessionRestoreResponse = Schema.Struct({ total: NonNegativeInt, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 03e8ee74b7..570f355e57 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -24,6 +24,7 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac return yield* workspace .create({ ...ctx.payload, + extra: ctx.payload.extra ?? null, projectID: instance.project.id, }) .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index e44a5ee3cd..48dcd885b2 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -27,9 +27,9 @@ const it = testEffect( Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer), ) -function request(path: string, directory: string, init: RequestInit = {}) { +function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) { return Effect.promise(() => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpApi const headers = new Headers(init.headers) headers.set("x-opencode-directory", directory) return Promise.resolve(Server.Default().app.request(path, { ...init, headers })) @@ -195,6 +195,55 @@ describe("workspace HttpApi", () => { }), ) + it.live("creates workspace with the TUI payload shape", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) + + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null }), + }) + + expect(created.status).toBe(200) + expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ + type: "local-test", + name: "local-test", + extra: null, + }) + }), + ) + + it.live("documents legacy Hono accepting the TUI payload shape", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace"))) + + const created = yield* request( + WorkspacePaths.list, + dir, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "local-test", branch: null }), + }, + false, + ) + + expect(created.status).toBe(200) + expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ + type: "local-test", + name: "local-test", + extra: null, + }) + }), + ) + it.live("routes local workspace requests through the workspace target directory", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true From 5242a1c6b462cf8dea1f9f9a4ddf7190341c558a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 10:49:44 -0400 Subject: [PATCH 022/178] fix(httpapi): install Instance ALS for adapter Promise bridge (#25417) --- .../opencode/src/control-plane/workspace.ts | 17 +++++++++-------- packages/opencode/src/effect/bridge.ts | 19 +++++++++++++++++++ .../httpapi/middleware/workspace-routing.ts | 7 +++---- .../test/server/httpapi-workspace.test.ts | 18 ++++++++++++++++++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 7e4b4a6ff4..485cb2e925 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -25,6 +25,7 @@ import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" import { waitEvent } from "./util" import { WorkspaceContext } from "./workspace-context" +import { EffectBridge } from "@/effect/bridge" import { NonNegativeInt, withStatics } from "@/util/schema" import { zod as effectZod, zodObject } from "@/util/effect-zod" @@ -336,7 +337,7 @@ export const layer = Layer.effect( const syncWorkspaceLoop = Effect.fn("Workspace.syncWorkspaceLoop")(function* (space: Info) { const adapter = getAdapter(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) if (target.type === "local") return @@ -420,7 +421,7 @@ export const layer = Layer.effect( if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) return const adapter = getAdapter(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) if (target.type === "local") { setStatus(space.id, (yield* Effect.promise(() => Filesystem.exists(target.directory))) ? "connected" : "error") @@ -459,8 +460,8 @@ export const layer = Layer.effect( const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) - const config = yield* Effect.promise(() => - Promise.resolve(adapter.configure({ ...input, id, name: Slug.create(), directory: null })), + const config = yield* EffectBridge.fromPromise(() => + adapter.configure({ ...input, id, name: Slug.create(), directory: null }), ) const info: Info = { @@ -496,7 +497,7 @@ export const layer = Layer.effect( OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } - yield* Effect.promise(() => adapter.create(config, env)) + yield* EffectBridge.fromPromise(() => adapter.create(config, env)) yield* Effect.all( [ waitEvent({ @@ -532,7 +533,7 @@ export const layer = Layer.effect( }) const adapter = getAdapter(space.projectID, space.type) - const target = yield* Effect.promise(() => Promise.resolve(adapter.target(space))) + const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) yield* sync.run(Session.Event.Updated, { sessionID: input.sessionID, @@ -724,10 +725,10 @@ export const layer = Layer.effect( yield* stopSync(id) const info = fromRow(row) - yield* Effect.catch( + yield* Effect.catchCause( Effect.gen(function* () { const adapter = getAdapter(info.projectID, row.type) - yield* Effect.tryPromise(() => Promise.resolve(adapter.remove(info))) + yield* EffectBridge.fromPromise(() => adapter.remove(info)) }), () => Effect.sync(() => { diff --git a/packages/opencode/src/effect/bridge.ts b/packages/opencode/src/effect/bridge.ts index 3c310129f1..16d8f93669 100644 --- a/packages/opencode/src/effect/bridge.ts +++ b/packages/opencode/src/effect/bridge.ts @@ -21,6 +21,25 @@ function restore(instance: InstanceContext | undefined, workspace: WorkspaceI return fn() } +/** + * Bridge from Effect into a Promise-returning JS callback while installing + * legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for + * the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do + * not propagate across async/await boundaries inside `Effect.promise(() => + * async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`, + * but Node's AsyncLocalStorage does. Use this whenever an Effect crosses + * into JS that may itself spawn new Effect runtimes (workspace adapters, + * legacy plugins, etc.). + * + * Mirrors `Effect.promise` but restores legacy ALS first. + */ +export const fromPromise = (fn: () => Promise | T): Effect.Effect => + Effect.gen(function* () { + const instance = yield* InstanceRef + const workspace = yield* WorkspaceRef + return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn()))) + }) + export function make(): Effect.Effect { return Effect.gen(function* () { const ctx = yield* Effect.context() diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index c8762bae66..4a07aaf11c 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -2,6 +2,7 @@ import { getAdapter } from "@/control-plane/adapters" import { WorkspaceID } from "@/control-plane/schema" import type { Target } from "@/control-plane/types" import { Workspace } from "@/control-plane/workspace" +import { EffectBridge } from "@/effect/bridge" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" import * as Fence from "@/server/fence" @@ -79,10 +80,8 @@ function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServe } function resolveTarget(workspace: Workspace.Info): Effect.Effect { - return Effect.gen(function* () { - const adapter = yield* Effect.sync(() => getAdapter(workspace.projectID, workspace.type)) - return yield* Effect.promise(() => Promise.resolve(adapter.target(workspace))) - }) + const adapter = getAdapter(workspace.projectID, workspace.type) + return EffectBridge.fromPromise(() => adapter.target(workspace)) } function proxyRemote( diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 48dcd885b2..7fc1ec761d 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -217,6 +217,24 @@ describe("workspace HttpApi", () => { }), ) + it.live("creates a real git worktree workspace via the builtin adapter", () => + Effect.gen(function* () { + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + const dir = yield* tmpdirScoped({ git: true }) + + const created = yield* request(WorkspacePaths.list, dir, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }) + + const body = yield* Effect.promise(() => created.text()) + expect({ status: created.status, body }).toMatchObject({ status: 200 }) + const workspace = JSON.parse(body) as Workspace.Info + expect(workspace).toMatchObject({ type: "worktree" }) + }), + ) + it.live("documents legacy Hono accepting the TUI payload shape", () => Effect.gen(function* () { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true From 4c4860fb24603ce2e1044bc9d2c98953ce2d78af Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 10:56:15 -0400 Subject: [PATCH 023/178] Replace Instance.disposeAll/load with fixture helper (#25418) --- packages/opencode/src/cli/bootstrap.ts | 4 ++-- packages/opencode/src/cli/cmd/tui/worker.ts | 3 ++- packages/opencode/src/config/config.ts | 12 +++++++++--- packages/opencode/src/effect/app-runtime.ts | 2 ++ packages/opencode/src/project/instance.ts | 11 +++-------- packages/opencode/src/server/routes/global.ts | 4 ++-- .../opencode/src/server/routes/instance/index.ts | 3 ++- .../opencode/src/server/routes/instance/project.ts | 6 +----- packages/opencode/test/agent/agent.test.ts | 4 ++-- packages/opencode/test/bus/bus-effect.test.ts | 4 ++-- packages/opencode/test/bus/bus-integration.test.ts | 6 +++--- packages/opencode/test/bus/bus.test.ts | 8 ++++---- packages/opencode/test/config/config.test.ts | 4 ++-- .../opencode/test/control-plane/workspace.test.ts | 4 ++-- packages/opencode/test/effect/instance-state.test.ts | 6 +++--- packages/opencode/test/file/index.test.ts | 6 +++--- packages/opencode/test/file/watcher.test.ts | 4 ++-- packages/opencode/test/fixture/db.ts | 4 ++-- packages/opencode/test/fixture/fixture.ts | 8 ++++++++ packages/opencode/test/permission-task.test.ts | 4 ++-- packages/opencode/test/permission/next.test.ts | 4 ++-- packages/opencode/test/plugin/loader-shared.test.ts | 4 ++-- .../opencode/test/plugin/workspace-adapter.test.ts | 4 ++-- packages/opencode/test/project/instance.test.ts | 4 ++-- packages/opencode/test/project/vcs.test.ts | 6 +++--- packages/opencode/test/project/worktree.test.ts | 4 ++-- packages/opencode/test/provider/provider.test.ts | 4 ++-- packages/opencode/test/question/question.test.ts | 4 ++-- packages/opencode/test/server/httpapi-bridge.test.ts | 4 ++-- packages/opencode/test/server/httpapi-config.test.ts | 4 ++-- packages/opencode/test/server/httpapi-event.test.ts | 4 ++-- .../test/server/httpapi-experimental.test.ts | 4 ++-- packages/opencode/test/server/httpapi-file.test.ts | 4 ++-- .../test/server/httpapi-instance-context.test.ts | 4 ++-- .../test/server/httpapi-instance.legacy.test.ts | 4 ++-- .../opencode/test/server/httpapi-instance.test.ts | 4 ++-- .../opencode/test/server/httpapi-json-parity.test.ts | 4 ++-- packages/opencode/test/server/httpapi-mcp.test.ts | 4 ++-- .../opencode/test/server/httpapi-provider.test.ts | 4 ++-- packages/opencode/test/server/httpapi-pty.test.ts | 4 ++-- .../test/server/httpapi-raw-route-auth.test.ts | 4 ++-- packages/opencode/test/server/httpapi-sdk.test.ts | 6 +++--- .../opencode/test/server/httpapi-session.test.ts | 4 ++-- packages/opencode/test/server/httpapi-sync.test.ts | 4 ++-- packages/opencode/test/server/httpapi-tui.test.ts | 4 ++-- .../opencode/test/server/httpapi-workspace.test.ts | 4 ++-- .../opencode/test/server/project-init-git.test.ts | 6 +++--- .../opencode/test/server/session-actions.test.ts | 4 ++-- packages/opencode/test/server/session-list.test.ts | 4 ++-- .../opencode/test/server/session-messages.test.ts | 4 ++-- packages/opencode/test/server/session-select.test.ts | 4 ++-- packages/opencode/test/snapshot/snapshot.test.ts | 4 ++-- packages/opencode/test/tool/edit.test.ts | 4 ++-- packages/opencode/test/tool/lsp.test.ts | 4 ++-- packages/opencode/test/tool/read.test.ts | 4 ++-- packages/opencode/test/tool/registry.test.ts | 4 ++-- packages/opencode/test/tool/skill.test.ts | 4 ++-- packages/opencode/test/tool/task.test.ts | 4 ++-- packages/opencode/test/tool/write.test.ts | 4 ++-- 59 files changed, 139 insertions(+), 130 deletions(-) diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 3190fda62f..aa6aef6a23 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,5 +1,5 @@ -import { AppRuntime } from "@/effect/app-runtime" import { Instance } from "../project/instance" +import { InstanceStore } from "../project/instance-store" export async function bootstrap(directory: string, cb: () => Promise) { return Instance.provide({ @@ -9,7 +9,7 @@ export async function bootstrap(directory: string, cb: () => Promise) { const result = await cb() return result } finally { - await Instance.dispose() + await InstanceStore.runtime.runPromise((s) => s.dispose(Instance.current)) } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 8b62c5038b..41ca99a715 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -2,6 +2,7 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" @@ -87,7 +88,7 @@ export const rpc = { async shutdown() { Log.Default.info("worker shutting down") - await Instance.disposeAll() + await InstanceStore.runtime.runPromise((s) => s.disposeAll()) if (server) await server.stop(true) }, } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 9e9a6e3810..4dcab3e8dc 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -11,7 +11,9 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" -import { Instance, type InstanceContext } from "../project/instance" +import { type InstanceContext } from "../project/instance" +import { InstanceStore } from "../project/instance-store" +import { InstanceRef } from "@/effect/instance-ref" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" import { GlobalBus } from "@/bus/global" @@ -736,12 +738,16 @@ export const layer = Layer.effect( yield* fs .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) .pipe(Effect.orDie) - if (options?.dispose !== false) yield* Effect.promise(() => Instance.dispose()) + if (options?.dispose !== false) { + const ctx = yield* InstanceRef + if (ctx) yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.dispose(ctx))) + } }) const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { yield* invalidateGlobal - const task = Instance.disposeAll() + const task = InstanceStore.runtime + .runPromise((s) => s.disposeAll()) .catch(() => undefined) .finally(() => GlobalBus.emit("event", { diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 06969ff9d1..f3376ad859 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -39,6 +39,7 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" @@ -90,6 +91,7 @@ export const AppLayer = Layer.mergeAll( Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, + InstanceStore.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index afc07ad26c..44ba397632 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -6,13 +6,11 @@ export type { InstanceContext } from "./instance-context" export type { LoadInput } from "./instance-store" export const Instance = { - load(input: InstanceStore.LoadInput): Promise { - return InstanceStore.runtime.runPromise((store) => store.load(input)) - }, async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { - return context.provide(await Instance.load({ directory: input.directory, init: input.init }), async () => - input.fn(), + const ctx = await InstanceStore.runtime.runPromise((store) => + store.load({ directory: input.directory, init: input.init }), ) + return context.provide(ctx, async () => input.fn()) }, get current() { return context.use() @@ -50,7 +48,4 @@ export const Instance = { async dispose() { return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) }, - async disposeAll() { - return InstanceStore.runtime.runPromise((store) => store.disposeAll()) - }, } diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index e78df61c2a..97fee3bfcf 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -8,7 +8,7 @@ import { SyncEvent } from "@/sync" import { GlobalBus } from "@/bus/global" import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" -import { Instance } from "../../project/instance" +import { InstanceStore } from "../../project/instance-store" import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" @@ -200,7 +200,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - await Instance.disposeAll() + await InstanceStore.runtime.runPromise((s) => s.disposeAll()) GlobalBus.emit("event", { directory: "global", payload: { diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index fa11e3e90d..6ee9b4fada 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -6,6 +6,7 @@ import z from "zod" import { Format } from "@/format" import { TuiRoutes } from "./tui" import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Vcs } from "@/project/vcs" import { Agent } from "@/agent/agent" import { Skill } from "@/skill" @@ -62,7 +63,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }), async (c) => { - await Instance.dispose() + await InstanceStore.runtime.runPromise((s) => s.dispose(Instance.current)) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 14c8c87b09..7db2bbddae 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -81,11 +81,7 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await Instance.reload({ - directory: dir, - worktree: dir, - project: next, - }) + await Instance.reload({ directory: dir, worktree: dir, project: next }) return c.json(next) }, ) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 06bb103f06..44ed0692a4 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,7 +1,7 @@ import { afterEach, test, expect } from "bun:test" import { Effect } from "effect" import path from "path" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" @@ -18,7 +18,7 @@ function load(dir: string, fn: (svc: Agent.Interface) => Effect.Effect) { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("returns default native agents when no config", async () => { diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 0daf8fe6a6..101d3be72b 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -4,7 +4,7 @@ import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const TestEvent = { @@ -151,7 +151,7 @@ describe("Bus (Effect-native)", () => { }).pipe(provideInstance(dir)) // Dispose from OUTSIDE the instance scope - yield* Effect.promise(() => Instance.disposeAll()) + yield* Effect.promise(disposeAllInstances) yield* Deferred.await(disposed).pipe(Effect.timeout("2 seconds")) expect(types).toContain("test.effect.ping") diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts index 2808344577..7e2138ea81 100644 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ b/packages/opencode/test/bus/bus-integration.test.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) @@ -12,7 +12,7 @@ function withInstance(directory: string, fn: () => Promise) { } describe("Bus integration: acquireRelease subscriber pattern", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) test("subscriber via callback facade receives events and cleans up on unsub", async () => { await using tmp = await tmpdir() @@ -78,7 +78,7 @@ describe("Bus integration: acquireRelease subscriber pattern", () => { await Bun.sleep(10) }) - await Instance.disposeAll() + await disposeAllInstances() await Bun.sleep(50) expect(received).toEqual([1]) diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index cdacdd5179..b24b79b33b 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -3,7 +3,7 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = { Ping: BusEvent.define("test.ping", Schema.Struct({ value: Schema.Number })), @@ -15,7 +15,7 @@ function withInstance(directory: string, fn: () => Promise) { } describe("Bus", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) describe("publish + subscribe", () => { test("subscriber is live immediately after subscribe returns", async () => { @@ -208,8 +208,8 @@ describe("Bus", () => { await Bun.sleep(10) }) - // Instance.disposeAll triggers the finalizer which publishes InstanceDisposed - await Instance.disposeAll() + // disposeAllInstances triggers the finalizer which publishes InstanceDisposed + await disposeAllInstances() await Bun.sleep(50) expect(received).toContain("test.ping") diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index c3ae249e57..5b2e91e374 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -12,7 +12,7 @@ import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" @@ -108,7 +108,7 @@ async function check(map: (dir: string) => string) { }, }) } finally { - await Instance.disposeAll() + await disposeAllInstances() ;(Global.Path as { config: string }).config = prev await clear() } diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 8545aef7f3..ddd10f2e06 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -21,7 +21,7 @@ import { ModelID, ProviderID } from "@/provider/schema" import { SyncEvent } from "@/sync" import { EventSequenceTable, EventTable } from "@/sync/event.sql" import { resetDatabase } from "../fixture/db" -import { provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceID } from "../../src/control-plane/schema" @@ -93,7 +93,7 @@ beforeEach(() => { afterEach(async () => { mock.restore() - await Instance.disposeAll() + await disposeAllInstances() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspacesFlag restoreEnv() await resetDatabase() diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 54b2b42c8f..02945ac53f 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -4,7 +4,7 @@ import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" import { Instance } from "../../src/project/instance" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(CrossSpawnSpawner.defaultLayer) @@ -19,7 +19,7 @@ const tmpdirGitScoped = Effect.gen(function* () { }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) it.live("InstanceState caches values per directory", () => @@ -94,7 +94,7 @@ it.live("InstanceState invalidates on disposeAll", () => yield* access(state, one) yield* access(state, two) - yield* Effect.promise(() => Instance.disposeAll()) + yield* Effect.promise(disposeAllInstances) expect(seen.sort()).toEqual([one, two].sort()) }), diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 091626be8d..bf5e7a175f 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -6,10 +6,10 @@ import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const init = () => run(File.Service.use((svc) => svc.init())) @@ -936,7 +936,7 @@ describe("file/index Filesystem patterns", () => { }, }) - await Instance.disposeAll() + await disposeAllInstances() await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") await fs.rm(path.join(tmp.path, "before.ts")) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 2c2663b0e0..e183f673f0 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { ConfigProvider, Deferred, Effect, Layer, ManagedRuntime, Option } from "effect" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Bus } from "../../src/bus" import { Config } from "@/config/config" import { FileWatcher } from "../../src/file/watcher" @@ -147,7 +147,7 @@ function ready(directory: string) { describeWatcher("FileWatcher", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("publishes root create, update, and delete events", async () => { diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts index 4e83d0b906..07b42d9946 100644 --- a/packages/opencode/test/fixture/db.ts +++ b/packages/opencode/test/fixture/db.ts @@ -1,9 +1,9 @@ import { rm } from "fs/promises" -import { Instance } from "../../src/project/instance" import { Database } from "@/storage/db" +import { disposeAllInstances } from "./fixture" export async function resetDatabase() { - await Instance.disposeAll().catch(() => undefined) + await disposeAllInstances().catch(() => undefined) Database.close() await rm(Database.Path, { force: true }).catch(() => undefined) await rm(`${Database.Path}-wal`, { force: true }).catch(() => undefined) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 91605e15ad..a861285a11 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -9,8 +9,16 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { TestLLMServer } from "../lib/llm-server" +// Test helper for tearing down all loaded instances. Used in afterEach hooks. +// Replaces direct Instance.disposeAll() calls so the legacy promise method can be removed. +// IMPORTANT: must use InstanceStore.runtime, not AppRuntime or a test-layer Service — +// Instance.provide loads instances into InstanceStore.runtime's Service cache, and that +// Service is built per-runtime (not shared via memoMap across Effect.runPromise boundaries). +export const disposeAllInstances = () => InstanceStore.runtime.runPromise((s) => s.disposeAll()) + // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { return p.replace(/\0/g, "") diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index 5ce7eee939..d4f9192c76 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -2,13 +2,13 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" import { Config } from "@/config/config" import { Instance } from "../src/project/instance" -import { tmpdir } from "./fixture/fixture" +import { disposeAllInstances, tmpdir } from "./fixture/fixture" import { AppRuntime } from "../src/effect/app-runtime" const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("Permission.evaluate for permission.task", () => { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 0064185f46..850ad2dedd 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,7 +6,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" -import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" @@ -15,7 +15,7 @@ const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, Cross const it = testEffect(env) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const rejectAll = (message?: string) => diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 211a93a602..e24cd05070 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Filesystem } from "@/util/filesystem" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS @@ -24,7 +24,7 @@ afterAll(() => { }) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function load(dir: string) { diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 9abf993d80..249087808d 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import path from "path" import { pathToFileURL } from "url" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS @@ -20,7 +20,7 @@ const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) afterAll(() => { diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index b223bf91db..852c58ef41 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -5,13 +5,13 @@ import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" -import { tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer)) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("InstanceStore", () => { diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index a2a5cff601..0d0e46fe48 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import fs from "fs/promises" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { AppRuntime } from "../../src/effect/app-runtime" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" @@ -85,7 +85,7 @@ function nextBranchUpdate(directory: string, timeout = 10_000) { describeVcs("Vcs", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("branch() returns current branch name", async () => { @@ -158,7 +158,7 @@ describeVcs("Vcs", () => { describe("Vcs diff", () => { afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) test("defaultBranch() falls back to main", async () => { diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 44a25a8e6b..fac82fad34 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -6,7 +6,7 @@ import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" import { Worktree } from "../../src/worktree" -import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) @@ -37,7 +37,7 @@ async function waitReady() { } describe("Worktree", () => { - afterEach(() => Instance.disposeAll()) + afterEach(() => disposeAllInstances()) describe("makeWorktreeInfo", () => { it.live("returns info with name, branch, and directory", () => diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index edbf4d6648..924f42888b 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2,7 +2,7 @@ import { test, expect } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" import { Instance } from "../../src/project/instance" import { Plugin } from "../../src/plugin/index" @@ -2557,7 +2557,7 @@ test("plugin config providers persist after instance dispose", async () => { expect(first[ProviderID.make("demo")]).toBeDefined() expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() - await Instance.disposeAll() + await disposeAllInstances() const second = await Instance.provide({ directory: tmp.path, diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index d44f41f1aa..14cf1aefa6 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -2,7 +2,7 @@ import { afterEach, test, expect } from "bun:test" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" import { QuestionID } from "../../src/question/schema" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" @@ -17,7 +17,7 @@ const reply = (input: { requestID: QuestionID; answers: ReadonlyArray AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id))) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) /** Reject all pending questions so dangling Deferred fibers don't hang the test. */ diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 2b8a62cc5f..352fb2e2fa 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -12,7 +12,7 @@ import { ConfigProvider, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -208,7 +208,7 @@ afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 9469a66fd5..7d269b6bed 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -6,7 +6,7 @@ import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -37,7 +37,7 @@ async function waitDisposed(directory: string) { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index 915d79784c..d7e48240a9 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -5,7 +5,7 @@ import { Server } from "../../src/server/server" import { EventPaths } from "../../src/server/routes/instance/httpapi/event" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -29,7 +29,7 @@ async function readFirstChunk(response: Response) { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index a4b0b66199..0185af2df9 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -10,7 +10,7 @@ import { Database } from "@/storage/db" import * as Log from "@opencode-ai/core/util/log" import { Worktree } from "../../src/worktree" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -50,7 +50,7 @@ async function waitReady(directory: string) { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index b7425007e1..81246eb0f0 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -6,7 +6,7 @@ import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { Instance } from "../../src/project/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -28,7 +28,7 @@ function request(route: string, directory: string, query?: Record { - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 15d3facd30..ece01cf323 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -19,7 +19,7 @@ import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/rou import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" -import { tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const testStateLayer = Layer.effectDiscard( @@ -30,7 +30,7 @@ const testStateLayer = Layer.effectDiscard( yield* Effect.addFinalizer(() => Effect.promise(async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }), ) diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts index 4f9ccc512a..22a56ba8a4 100644 --- a/packages/opencode/test/server/httpapi-instance.legacy.test.ts +++ b/packages/opencode/test/server/httpapi-instance.legacy.test.ts @@ -6,7 +6,7 @@ import { Server } from "../../src/server/server" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -37,7 +37,7 @@ async function waitDisposed(directory: string) { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-instance.test.ts b/packages/opencode/test/server/httpapi-instance.test.ts index 3d9245cd6f..61b1af6135 100644 --- a/packages/opencode/test/server/httpapi-instance.test.ts +++ b/packages/opencode/test/server/httpapi-instance.test.ts @@ -7,13 +7,13 @@ import * as Socket from "effect/unstable/socket/Socket" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { resetDatabase } from "../fixture/db" -import { tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" // Flip the experimental HttpApi flag so backend selection telemetry on the // production routes reports the right backend, and reset the database around // the test so per-instance state does not leak between runs. resetDatabase() -// already calls Instance.disposeAll(), so we don't repeat it. +// already calls disposeAllInstances(), so we don't repeat it. const testStateLayer = Layer.effectDiscard( Effect.gen(function* () { const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI diff --git a/packages/opencode/test/server/httpapi-json-parity.test.ts b/packages/opencode/test/server/httpapi-json-parity.test.ts index 0465b1cf6f..656541be71 100644 --- a/packages/opencode/test/server/httpapi-json-parity.test.ts +++ b/packages/opencode/test/server/httpapi-json-parity.test.ts @@ -15,7 +15,7 @@ import { MessageID, PartID } from "../../src/session/schema" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" void Log.init({ print: false }) @@ -89,7 +89,7 @@ function expectJsonParity(input: { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index e348866528..d81d749f1d 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -8,7 +8,7 @@ import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" void Log.init({ print: false }) @@ -76,7 +76,7 @@ const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: s afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 5e8ff01a0e..3ff3893005 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -6,7 +6,7 @@ import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance } from "../fixture/fixture" +import { disposeAllInstances, provideInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" void Log.init({ print: false }) @@ -98,7 +98,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index e4d22427cb..2b6284a310 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -7,7 +7,7 @@ import { Server } from "../../src/server/server" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture" import { Config, Effect, Layer, Queue, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" @@ -63,7 +63,7 @@ const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts index af373d933b..fd82e78639 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -8,7 +8,7 @@ import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { PtyID } from "../../src/pty/schema" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import * as Log from "@opencode-ai/core/util/log" void Log.init({ print: false }) @@ -49,7 +49,7 @@ async function cancelBody(response: Response) { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 596ca4a5c4..771fb57019 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -15,7 +15,7 @@ import { Session as SessionNs } from "@/session/session" import { TestLLMServer } from "../lib/llm-server" import path from "path" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" const original = { @@ -169,7 +169,7 @@ function sessionTitles(value: unknown) { function resetState() { return Effect.promise(async () => { - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) } @@ -260,7 +260,7 @@ afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 5f2af06f1e..02d590f918 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -20,7 +20,7 @@ import { SessionTable } from "@/session/session.sql" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { it } from "../lib/effect" void Log.init({ print: false }) @@ -138,7 +138,7 @@ function withTmp( afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index f51a714575..d022c37974 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -7,7 +7,7 @@ import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -27,7 +27,7 @@ afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 3e844fad02..1fd3ce2b39 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -11,7 +11,7 @@ import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -45,7 +45,7 @@ async function expectTrue(path: string, headers: Record, body?: afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 7fc1ec761d..193c2971a1 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -12,7 +12,7 @@ import { Session } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Project } from "../../src/project/project" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" @@ -128,7 +128,7 @@ afterEach(async () => { mock.restore() Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi - await Instance.disposeAll() + await disposeAllInstances() await resetDatabase() }) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 86c52096eb..0177cde82f 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -8,7 +8,7 @@ import { Server } from "../../src/server/server" import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -69,7 +69,7 @@ describe("project.initGit endpoint", () => { ), ).toBeTruthy() } finally { - await Instance.disposeAll() + await disposeAllInstances() reloadSpy.mockRestore() GlobalBus.off("event", fn) } @@ -114,7 +114,7 @@ describe("project.initGit endpoint", () => { worktree: tmp.path, }) } finally { - await Instance.disposeAll() + await disposeAllInstances() reloadSpy.mockRestore() GlobalBus.off("event", fn) } diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 843986ba8c..43f188e741 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -5,7 +5,7 @@ import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -25,7 +25,7 @@ const svc = { afterEach(async () => { mock.restore() - await Instance.disposeAll() + await disposeAllInstances() }) describe("session action routes", () => { diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index e2f92c20f6..7d479a73b0 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { Instance } from "../../src/project/instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Flag } from "@opencode-ai/core/flag/flag" import { mkdir } from "fs/promises" import path from "path" @@ -30,7 +30,7 @@ const svc = { afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces - await Instance.disposeAll() + await disposeAllInstances() }) describe("session.list", () => { diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index e64d28bb44..e70847baf2 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -6,7 +6,7 @@ import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -31,7 +31,7 @@ const svc = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function withoutWatcher(fn: () => Promise) { diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index 278fba94dc..b3230d4b8a 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -5,7 +5,7 @@ import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" void Log.init({ print: false }) @@ -24,7 +24,7 @@ const svc = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("tui.selectSession endpoint", () => { diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index b85d570dc5..c3216e1c58 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -6,7 +6,7 @@ import { Effect } from "effect" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" // Git always outputs /-separated paths internally. Snapshot.patch() joins them // with path.join (which produces \ on Windows) then normalizes back to /. @@ -14,7 +14,7 @@ import { provideInstance, tmpdir } from "../fixture/fixture" const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/") afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) async function bootstrap() { diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 01dc74bb22..2c381ad047 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -4,7 +4,7 @@ import fs from "fs/promises" import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -26,7 +26,7 @@ const ctx = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const runtime = ManagedRuntime.make( diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index 8a3189cab9..27623375c2 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -11,11 +11,11 @@ import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" import { LspTool } from "../../src/tool/lsp" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ctx = { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index c20b084372..3fa61401e1 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -13,13 +13,13 @@ import { ReadTool } from "../../src/tool/read" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" -import { provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ctx = { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 8e03177f88..0cd3ec4d18 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -5,7 +5,7 @@ import { Effect, Layer } from "effect" import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const node = CrossSpawnSpawner.defaultLayer @@ -13,7 +13,7 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) describe("tool.registry", () => { diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 175c1526d0..7473d2d56a 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -8,7 +8,7 @@ import type { Tool } from "@/tool/tool" import { Instance } from "../../src/project/instance" import { SkillTool } from "../../src/tool/skill" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" @@ -23,7 +23,7 @@ const baseCtx: Omit = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const node = CrossSpawnSpawner.defaultLayer diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 147541f3d2..a8d62bb68c 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -12,11 +12,11 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const ref = { diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index cc9f87100c..4931d2a544 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -28,7 +28,7 @@ const ctx = { } afterEach(async () => { - await Instance.disposeAll() + await disposeAllInstances() }) const it = testEffect( From 78b3000031d3224d46da667dc631a04e7647d0f6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 10:56:27 -0400 Subject: [PATCH 024/178] fix(tui): keep shell-mode prompt editable (#25419) --- .../cli/cmd/tui/component/prompt/index.tsx | 17 +++------ .../cli/cmd/tui/component/prompt/traits.ts | 31 +++++++++++++++ .../test/cli/cmd/tui/prompt-traits.test.ts | 38 +++++++++++++++++++ 3 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts create mode 100644 packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 1f93a43947..79034a01bb 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -17,6 +17,7 @@ import { MessageID, PartID } from "@/session/schema" import { createStore, produce, unwrap } from "solid-js/store" import { useKeybind } from "@tui/context/keybind" import { usePromptHistory, type PromptInfo } from "./history" +import { computePromptTraits } from "./traits" import { assign } from "./part" import { usePromptStash } from "./stash" import { DialogStash } from "../dialog-stash" @@ -557,17 +558,11 @@ export function Prompt(props: PromptProps) { createEffect(() => { if (!input || input.isDestroyed) return - const capture = - store.mode === "normal" - ? auto()?.visible - ? (["escape", "navigate", "submit", "tab"] as const) - : (["tab"] as const) - : undefined - input.traits = { - capture, - suspend: !!props.disabled || store.mode === "shell", - status: store.mode === "shell" ? "SHELL" : undefined, - } + input.traits = computePromptTraits({ + mode: store.mode, + disabled: !!props.disabled, + autocompleteVisible: !!auto()?.visible, + }) }) function restoreExtmarksFromParts(parts: PromptInfo["parts"]) { diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts new file mode 100644 index 0000000000..e47a1aeba5 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/traits.ts @@ -0,0 +1,31 @@ +import type { EditorTraits } from "@opentui/core" + +export type PromptMode = "normal" | "shell" + +export interface PromptTraitsInput { + mode: PromptMode + disabled: boolean + autocompleteVisible: boolean +} + +/** + * Compute the textarea editor traits for the prompt. + * + * `traits.suspend` gates the textarea's keybinding actions (backspace, + * delete-word, arrow movement, undo/redo, etc.). Shell mode is an active + * editing mode — only `disabled` should suspend the textarea, otherwise + * users can type in shell mode but cannot delete or move the cursor. + */ +export function computePromptTraits(input: PromptTraitsInput): EditorTraits { + const capture = + input.mode === "normal" + ? input.autocompleteVisible + ? (["escape", "navigate", "submit", "tab"] as const) + : (["tab"] as const) + : undefined + return { + capture, + suspend: input.disabled, + status: input.mode === "shell" ? "SHELL" : undefined, + } +} diff --git a/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts new file mode 100644 index 0000000000..34a16aedd6 --- /dev/null +++ b/packages/opencode/test/cli/cmd/tui/prompt-traits.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { computePromptTraits } from "../../../../src/cli/cmd/tui/component/prompt/traits" + +describe("computePromptTraits", () => { + test("normal mode without autocomplete only captures tab", () => { + const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: false }) + expect(traits.capture).toEqual(["tab"]) + expect(traits.suspend).toBe(false) + expect(traits.status).toBeUndefined() + }) + + test("normal mode with autocomplete captures navigation keys", () => { + const traits = computePromptTraits({ mode: "normal", disabled: false, autocompleteVisible: true }) + expect(traits.capture).toEqual(["escape", "navigate", "submit", "tab"]) + expect(traits.suspend).toBe(false) + expect(traits.status).toBeUndefined() + }) + + test("shell mode does not suspend the textarea", () => { + // Suspending the textarea would gate every keybinding action + // (backspace, delete-word-backward, arrow movement, etc.) — see + // @opentui/core 0.2.x TextareaRenderable.handleKeyPress. Shell mode is + // an active editing mode, so suspend must stay off. + const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + expect(traits.suspend).toBe(false) + }) + + test("shell mode disables capture and labels the prompt", () => { + const traits = computePromptTraits({ mode: "shell", disabled: false, autocompleteVisible: false }) + expect(traits.capture).toBeUndefined() + expect(traits.status).toBe("SHELL") + }) + + test("disabled suspends regardless of mode", () => { + expect(computePromptTraits({ mode: "normal", disabled: true, autocompleteVisible: false }).suspend).toBe(true) + expect(computePromptTraits({ mode: "shell", disabled: true, autocompleteVisible: false }).suspend).toBe(true) + }) +}) From 6a7634673460994c090c103097d9ff365a58cd17 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 2 May 2026 17:01:53 +0200 Subject: [PATCH 025/178] upgrade opentui to 0.2.2 (#25420) --- bun.lock | 56 ++++++++----------- package.json | 4 +- packages/opencode/src/cli/cmd/tui/app.tsx | 2 + .../src/cli/cmd/tui/context/theme.tsx | 8 ++- packages/plugin/package.json | 4 +- 5 files changed, 36 insertions(+), 38 deletions(-) diff --git a/bun.lock b/bun.lock index fcd8e94431..a6dc1df844 100644 --- a/bun.lock +++ b/bun.lock @@ -511,8 +511,8 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.2.0", - "@opentui/solid": ">=0.2.0", + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2", }, "optionalPeers": [ "@opentui/core", @@ -690,8 +690,8 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.2.0", - "@opentui/solid": "0.2.0", + "@opentui/core": "0.2.2", + "@opentui/solid": "0.2.2", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@sentry/solid": "10.36.0", @@ -1618,21 +1618,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/core": ["@opentui/core@0.2.0", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.2.0", "@opentui/core-darwin-x64": "0.2.0", "@opentui/core-linux-arm64": "0.2.0", "@opentui/core-linux-x64": "0.2.0", "@opentui/core-win32-arm64": "0.2.0", "@opentui/core-win32-x64": "0.2.0", "bun-webgpu": "0.1.7", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-7YOEqPUQmsgrOb9nmLEBlX8RVHPFy4HquK1C489DwfvvPTiws8nTbZ+webNQDWha7shgnYQK4Zo1EcOlpQ5+1Q=="], + "@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VVmKwth3hzsQPjAZ7WGJxmzuzx0uCtynd79JJDg26D7QRM9V5beVGbKwwU5SKsDlK74EyQoY85Mv9xFY5E4jrA=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-eX+WNdbSNr7Bozdq/MH6p1vXIALGt0SqBHR4YtWyTh6X7KDz9FTtJT3ylxMPqiVRUGBNAiWOxoqKGXW7JLQ0TA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ARZa+ywbN/OV7esT5ZdJMlQW3a4Pr56qLlEI/X65ik88C2sgmDze4Kf2FmqtvJ1hbv1YsMfLHH9MfhLl5twyHQ=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZjNxrD45P51cdbABoivVQLBakVYwDqAridJbHhkK6T/+EU7YsTrmAu9ae19N9ZGnrlKzLViQF8GOavNUNjAbhw=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ImMjFPOWE8wcZQ2lUz1D418xonS/5EwnItUF1g5dbp1q9+A0vv2P3bxTenLwMqcYvG4wjO6gKT3n2QLnRd6qKg=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-6yfYHTtJ4yzbl8kXCW3Pc4eWbZDYVw21GumwdNgkjJJ2JqQAQ861em0riEoucYAa5qPYYTiMUEw7X4Fv8lGwuQ=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="], - "@opentui/solid": ["@opentui/solid@0.2.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-kZR9i0FPAcVtomrPsKuSb+D9smooplo9zggFfU2vnnguNuQjGNbEmuJtxhCacy7ig9g3GomdNtQAzD4LiAY+3w=="], + "@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2768,21 +2768,21 @@ "builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="], - "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], + "bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], - "bun-webgpu": ["bun-webgpu@0.1.7", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.7", "bun-webgpu-darwin-x64": "^0.1.7", "bun-webgpu-linux-x64": "^0.1.7", "bun-webgpu-win32-x64": "^0.1.7" } }, "sha512-KUxUp+oQIf7pPBMD4Hv1TUu7DWaOZ4ciKulTk9to9+Uc8yHoYrMW7L2SJCJ4FHHkywgf/7aLRgRx0b7i6DvGIQ=="], + "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], - "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mRrFFyHzPWjsTRidAZBRcu808CPQBOUL0P6b4nxLhp+XHcV/mbUHERZMgW9s58tsojQfSdzschiQa8q+JCgRWA=="], + "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="], - "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-g0NXGNgvaVCSH/jCWWlfdiquOHkbUN6vP4zqzSkIxWKQeLnqm3oADcok7SO3yIgI7v5mKpRc/ks7NDEKNH+jNQ=="], + "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="], - "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-UEP7UZdEhx9otvkZczjsszL8ZVlrODANQvgl+C88/bNVmxDoFi7w1fWzGi1sZyakiETjmtFDq2/xCLhbSZxjqw=="], + "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="], - "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-KZktiFkBz6sN7PEm1NVdeaLP5Q5X/PlSHZqefY4nNuWtf0LNvh54NhZe7yVv/Plz/nGbv92b0KHMBY3ki/pp6g=="], + "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -4204,7 +4204,7 @@ "pagefind": ["pagefind@1.5.2", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.5.2", "@pagefind/darwin-x64": "1.5.2", "@pagefind/freebsd-x64": "1.5.2", "@pagefind/linux-arm64": "1.5.2", "@pagefind/linux-x64": "1.5.2", "@pagefind/windows-arm64": "1.5.2", "@pagefind/windows-x64": "1.5.2" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q=="], - "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="], @@ -5640,6 +5640,8 @@ "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], + "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], @@ -6122,8 +6124,6 @@ "type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="], - "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=="], "unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], @@ -6132,6 +6132,8 @@ "uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="], @@ -6798,7 +6800,7 @@ "opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="], - "opentui-spinner/@opentui/core/bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], + "opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], "opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], @@ -7158,16 +7160,6 @@ "opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "opentui-spinner/@opentui/core/bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="], - - "opentui-spinner/@opentui/core/bun-webgpu/bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="], - "opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], diff --git a/package.json b/package.json index 9a0113030c..b15fbb2544 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", - "@opentui/core": "0.2.0", - "@opentui/solid": "0.2.0", + "@opentui/core": "0.2.2", + "@opentui/solid": "0.2.2", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index a26b8cfdfe..7117ae7d1b 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -133,6 +133,8 @@ export function tui(input: { } const renderer = await createCliRenderer(rendererConfig(input.config)) + // Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash. + void renderer.getPalette({ size: 16 }).catch(() => undefined) const mode = (await renderer.waitForThemeMode(1000)) ?? "dark" await render(() => { diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 5c26d461e5..306c03825e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -416,12 +416,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const values = createMemo(() => { const active = store.themes[store.active] - if (active) return resolveTheme(active, store.mode) + if (active) { + return resolveTheme(active, store.mode) + } const saved = kv.get("theme") if (typeof saved === "string") { const theme = store.themes[saved] - if (theme) return resolveTheme(theme, store.mode) + if (theme) { + return resolveTheme(theme, store.mode) + } } return resolveTheme(store.themes.opencode, store.mode) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 75aba38d1b..6efce0c576 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -22,8 +22,8 @@ "zod": "catalog:" }, "peerDependencies": { - "@opentui/core": ">=0.2.0", - "@opentui/solid": ">=0.2.0" + "@opentui/core": ">=0.2.2", + "@opentui/solid": ">=0.2.2" }, "peerDependenciesMeta": { "@opentui/core": { From 31ed4602e14d44d840a579bca871be955ff49391 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 15:16:12 +0000 Subject: [PATCH 026/178] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index b9ba578ac6..bea97a0cb3 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-OtyfKTBEHsJpjzAjN9vCR0PzGzdK6CDHdyU7eZ6Gl1s=", - "aarch64-linux": "sha256-3eHJs3S/+uDUPAouWPsdBOlEvAOhOYx5bJzahL0tAJk=", - "aarch64-darwin": "sha256-rFXzrkhPVb3yM20J8R8m7GqroNNk1vAEz+o/Ks+iAI4=", - "x86_64-darwin": "sha256-lb1IGgbpxg723Qxj2WVPkxKUUmyOIsFOAhA5LoZ8GwY=" + "x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=", + "aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=", + "aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=", + "x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U=" } } From b09b7d28b801a4c4f3f1c691345a35a0c62aafd6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 11:21:40 -0400 Subject: [PATCH 027/178] refactor(instance-store): consolidate dispose helpers (#25424) --- packages/opencode/src/cli/bootstrap.ts | 2 +- packages/opencode/src/cli/cmd/tui/worker.ts | 2 +- packages/opencode/src/config/config.ts | 11 ++++++----- packages/opencode/src/project/instance-store.ts | 13 ++++++++++--- packages/opencode/src/project/instance.ts | 11 +++++++++-- packages/opencode/src/server/routes/global.ts | 2 +- .../opencode/src/server/routes/instance/index.ts | 2 +- .../opencode/src/server/routes/instance/project.ts | 6 +++++- packages/opencode/test/fixture/fixture.ts | 10 +++------- 9 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index aa6aef6a23..a0dd9fe2a1 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -9,7 +9,7 @@ export async function bootstrap(directory: string, cb: () => Promise) { const result = await cb() return result } finally { - await InstanceStore.runtime.runPromise((s) => s.dispose(Instance.current)) + await InstanceStore.disposeInstance(Instance.current) } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 41ca99a715..0f0fd693d1 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -88,7 +88,7 @@ export const rpc = { async shutdown() { Log.Default.info("worker shutting down") - await InstanceStore.runtime.runPromise((s) => s.disposeAll()) + await InstanceStore.disposeAllInstances() if (server) await server.stop(true) }, } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 4dcab3e8dc..46a31cf1c4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -13,7 +13,6 @@ import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" import { type InstanceContext } from "../project/instance" import { InstanceStore } from "../project/instance-store" -import { InstanceRef } from "@/effect/instance-ref" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" import { GlobalBus } from "@/bus/global" @@ -739,15 +738,17 @@ export const layer = Layer.effect( .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) .pipe(Effect.orDie) if (options?.dispose !== false) { - const ctx = yield* InstanceRef - if (ctx) yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.dispose(ctx))) + // Fail loudly if no instance is bound — silently skipping would + // mask "config update without an active instance" bugs. The throw + // comes from `Instance.current` inside `InstanceState.context`. + const ctx = yield* InstanceState.context + yield* Effect.promise(() => InstanceStore.disposeInstance(ctx)) } }) const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { yield* invalidateGlobal - const task = InstanceStore.runtime - .runPromise((s) => s.disposeAll()) + const task = InstanceStore.disposeAllInstances() .catch(() => undefined) .finally(() => GlobalBus.emit("event", { diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index e96c421a76..00075be64b 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -1,7 +1,7 @@ import { GlobalBus } from "@/bus/global" import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceRef } from "@/effect/instance-ref" -import { disposeInstance } from "@/effect/instance-registry" +import { disposeInstance as runDisposers } from "@/effect/instance-registry" import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" @@ -94,7 +94,7 @@ export const layer: Layer.Layer = Layer.effect( const disposeContext = Effect.fn("InstanceStore.disposeContext")(function* (ctx: InstanceContext) { yield* Effect.logInfo("disposing instance", { directory: ctx.directory }) - yield* Effect.promise(() => disposeInstance(ctx.directory)) + yield* Effect.promise(() => runDisposers(ctx.directory)) yield* emitDisposed({ directory: ctx.directory, project: ctx.project.id }) }) @@ -135,7 +135,7 @@ export const layer: Layer.Layer = Layer.effect( yield* Effect.logInfo("reloading instance", { directory }) if (previous) { yield* Deferred.await(previous.deferred).pipe(Effect.ignore) - yield* Effect.promise(() => disposeInstance(directory)) + yield* Effect.promise(() => runDisposers(directory)) yield* emitDisposed({ directory, project: input.project?.id }) } yield* completeLoad(directory, input, entry) @@ -197,4 +197,11 @@ export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer)) export const runtime = makeRuntime(Service, defaultLayer) +// Promise-returning helpers for callers without an Effect runtime in scope. +// They route through `runtime` (not a yielded Service from a fresh runtime) +// so they share the cache that `Instance.provide` populates. +export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx)) +export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll()) +export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input)) + export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 44ba397632..22c2779ce1 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -42,10 +42,17 @@ export const Instance = { restore(ctx: InstanceContext, fn: () => R): R { return context.provide(ctx, fn) }, + // followup: `reload` survives because `test/server/project-init-git.test.ts` + // spies on this exact method. Once that test asserts on `InstanceStore.reloadInstance` + // (or moves to an Effect runtime), this wrapper can drop. async reload(input: InstanceStore.LoadInput) { - return InstanceStore.runtime.runPromise((store) => store.reload(input)) + return InstanceStore.reloadInstance(input) }, + // followup: `dispose` survives for legacy fixtures that read `Instance.current` + // out of ALS (e.g. `test/fixture/fixture.ts` `provideTmpdirInstance`, + // `test/question/question.test.ts` cancellation tests). Convert those to call + // `InstanceStore.disposeInstance(ctx)` directly once `Instance.provide` is gone. async dispose() { - return InstanceStore.runtime.runPromise((store) => store.dispose(Instance.current)) + return InstanceStore.disposeInstance(Instance.current) }, } diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 97fee3bfcf..f40a584536 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -200,7 +200,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - await InstanceStore.runtime.runPromise((s) => s.disposeAll()) + await InstanceStore.disposeAllInstances() GlobalBus.emit("event", { directory: "global", payload: { diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 6ee9b4fada..530c02345a 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -63,7 +63,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }), async (c) => { - await InstanceStore.runtime.runPromise((s) => s.dispose(Instance.current)) + await InstanceStore.disposeInstance(Instance.current) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 7db2bbddae..14c8c87b09 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -81,7 +81,11 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await Instance.reload({ directory: dir, worktree: dir, project: next }) + await Instance.reload({ + directory: dir, + worktree: dir, + project: next, + }) return c.json(next) }, ) diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index a861285a11..23dd61d880 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -9,15 +9,11 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" import { TestLLMServer } from "../lib/llm-server" -// Test helper for tearing down all loaded instances. Used in afterEach hooks. -// Replaces direct Instance.disposeAll() calls so the legacy promise method can be removed. -// IMPORTANT: must use InstanceStore.runtime, not AppRuntime or a test-layer Service — -// Instance.provide loads instances into InstanceStore.runtime's Service cache, and that -// Service is built per-runtime (not shared via memoMap across Effect.runPromise boundaries). -export const disposeAllInstances = () => InstanceStore.runtime.runPromise((s) => s.disposeAll()) +// Re-export for test ergonomics. The implementation lives next to the runtime +// it consumes; see `InstanceStore.disposeAllInstances` for the rationale. +export { disposeAllInstances } from "../../src/project/instance-store" // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { From 7371db5cc6da57deb9e6fe776298f14410e5614d Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 2 May 2026 15:34:12 +0000 Subject: [PATCH 028/178] sync release versions for v1.14.32 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index a6dc1df844..2efb1208c3 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.31", + "version": "1.14.32", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.31", + "version": "1.14.32", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 2decf1fce4..7196ddd4fd 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.31", + "version": "1.14.32", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index afb9033779..45eb7d0b70 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 3ef4ad2e3d..e94be94983 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.31", + "version": "1.14.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 92b62a1bfe..c8590d6aad 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.31", + "version": "1.14.32", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index b29e0d8878..f72d7f100b 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.31", + "version": "1.14.32", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 5cbf063d39..b1e8aa635a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.31", + "version": "1.14.32", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 16eaad1587..5089278bfb 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index b2d2c975c7..f16bf93687 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7d93297d02..b4c487f2f8 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.31", + "version": "1.14.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 340a747d52..ecc7e8f6bb 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.31" +version = "1.14.32" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.31/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index d7536425c6..52dfab2adf 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.31", + "version": "1.14.32", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ea91bef74b..706986e426 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.31", + "version": "1.14.32", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 6efce0c576..2a96f1b8f3 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 3da6b1b8a9..8729c96a55 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 01c0ab245c..32d830bba7 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index c350b1e306..59039f05be 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.31", + "version": "1.14.32", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 093d4d91a5..ab8031cf95 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.31", + "version": "1.14.32", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index f107f9fa5e..185bc93399 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.31", + "version": "1.14.32", "publisher": "sst-dev", "repository": { "type": "git", From 3b9155714d1023182b75730ff4acd0a0c6a7cbf7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 11:44:16 -0400 Subject: [PATCH 029/178] Delete Instance.dispose and Instance.reload (#25427) --- packages/opencode/src/project/instance.ts | 13 ----------- .../src/server/routes/instance/project.ts | 7 ++---- .../test/effect/instance-state.test.ts | 5 ++-- packages/opencode/test/fixture/fixture.ts | 3 ++- packages/opencode/test/mcp/lifecycle.test.ts | 3 ++- .../opencode/test/permission/next.test.ts | 7 +++--- .../opencode/test/project/worktree.test.ts | 5 ++-- .../opencode/test/question/question.test.ts | 5 ++-- .../opencode/test/server/httpapi-mcp.test.ts | 3 ++- .../test/server/httpapi-provider.test.ts | 3 ++- .../test/server/project-init-git.test.ts | 23 ++++++------------- 11 files changed, 30 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 22c2779ce1..5b2bcf6b32 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -42,17 +42,4 @@ export const Instance = { restore(ctx: InstanceContext, fn: () => R): R { return context.provide(ctx, fn) }, - // followup: `reload` survives because `test/server/project-init-git.test.ts` - // spies on this exact method. Once that test asserts on `InstanceStore.reloadInstance` - // (or moves to an Effect runtime), this wrapper can drop. - async reload(input: InstanceStore.LoadInput) { - return InstanceStore.reloadInstance(input) - }, - // followup: `dispose` survives for legacy fixtures that read `Instance.current` - // out of ALS (e.g. `test/fixture/fixture.ts` `provideTmpdirInstance`, - // `test/question/question.test.ts` cancellation tests). Convert those to call - // `InstanceStore.disposeInstance(ctx)` directly once `Instance.provide` is gone. - async dispose() { - return InstanceStore.disposeInstance(Instance.current) - }, } diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 14c8c87b09..04cc432d08 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -2,6 +2,7 @@ import { Hono } from "hono" import { describeRoute, validator } from "hono-openapi" import { resolver } from "hono-openapi" import { Instance } from "@/project/instance" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import z from "zod" import { ProjectID } from "@/project/schema" @@ -81,11 +82,7 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await Instance.reload({ - directory: dir, - worktree: dir, - project: next, - }) + await InstanceStore.reloadInstance({ directory: dir, worktree: dir, project: next }) return c.json(next) }, ) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 02945ac53f..0a8972ca4a 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -3,6 +3,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" +import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -69,7 +70,7 @@ it.live("InstanceState invalidates on reload", () => ) const a = yield* access(state, dir) - yield* Effect.promise(() => Instance.reload({ directory: dir })) + yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) const b = yield* access(state, dir) expect(a).not.toBe(b) @@ -269,7 +270,7 @@ it.live("InstanceState correct after interleaved init and dispose", () => const [, b] = yield* Effect.all( [ - Effect.promise(() => Instance.reload({ directory: one })), + Effect.promise(() => InstanceStore.reloadInstance({ directory: one })), Test.use((svc) => svc.get()).pipe(provideInstance(two)), ], { concurrency: "unbounded" }, diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 23dd61d880..1b193e382a 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -8,6 +8,7 @@ import type * as Scope from "effect/Scope" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" import { TestLLMServer } from "../lib/llm-server" @@ -149,7 +150,7 @@ export function provideTmpdirInstance( ? Effect.promise(() => Instance.provide({ directory: path, - fn: () => Instance.dispose(), + fn: () => InstanceStore.disposeInstance(Instance.current), }), ).pipe(Effect.ignore) : Effect.void, diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 1b459481f3..59fa54ceab 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,4 +1,5 @@ import { test, expect, mock, beforeEach } from "bun:test" +import { InstanceStore } from "../../src/project/instance-store" import { Effect } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -197,7 +198,7 @@ function withInstance( fn: async () => { await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) // dispose instance to clean up state between tests - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) }, }) } diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 850ad2dedd..5a0c474021 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,6 +6,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" @@ -998,7 +999,7 @@ it.live("pending permission rejects on instance dispose", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void Instance.dispose() })) + yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -1021,7 +1022,7 @@ it.live("pending permission rejects on instance reload", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => Instance.reload({ directory: dir })) + yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -1115,7 +1116,7 @@ it.live("ask - abort should clear pending request", () => const pending = yield* waitForPending(1).pipe(run) expect(pending).toHaveLength(1) - yield* Effect.promise(() => Instance.reload({ directory: dir })) + yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index fac82fad34..3593c3ba00 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,6 +5,7 @@ import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -136,7 +137,7 @@ describe("Worktree", () => { expect(props.name).toBe(info.name) expect(props.branch).toBe(info.branch) - yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory)) + yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)))) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), @@ -156,7 +157,7 @@ describe("Worktree", () => { expect(info.branch).toBe("opencode/test-workspace") yield* Effect.promise(() => ready) - yield* Effect.promise(() => Instance.dispose()).pipe(provideInstance(info.directory)) + yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)))) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 14cf1aefa6..83968a6f8c 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,6 +1,7 @@ import { afterEach, test, expect } from "bun:test" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { QuestionID } from "../../src/question/schema" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" @@ -421,7 +422,7 @@ test("pending question rejects on instance dispose", async () => { fn: async () => { const items = await list() expect(items).toHaveLength(1) - await Instance.dispose() + await InstanceStore.disposeInstance(Instance.current) }, }) @@ -456,7 +457,7 @@ test("pending question rejects on instance reload", async () => { fn: async () => { const items = await list() expect(items).toHaveLength(1) - await Instance.reload({ directory: tmp.path }) + await InstanceStore.reloadInstance({ directory: tmp.path }) }, }) diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index d81d749f1d..32f6343ad8 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -5,6 +5,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -57,7 +58,7 @@ function withMcpProject(self: (dir: string) => Effect.Effect) }), ) yield* Effect.addFinalizer(() => - Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore), + Effect.promise(() => Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) })).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 3ff3893005..5714f719a5 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -3,6 +3,7 @@ import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { InstanceStore } from "../../src/project/instance-store" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -89,7 +90,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect - Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore), + Effect.promise(() => Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) })).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 0177cde82f..48e28aa5ac 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -1,9 +1,8 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import path from "path" import { GlobalBus } from "../../src/bus/global" import { Snapshot } from "../../src/snapshot" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" @@ -16,6 +15,9 @@ afterEach(async () => { await resetDatabase() }) +const disposedEvents = (seen: { directory?: string; payload: { type: string } }[], dir: string) => + seen.filter((evt) => evt.directory === dir && evt.payload.type === "server.instance.disposed").length + describe("project.initGit endpoint", () => { test("initializes git and reloads immediately", async () => { await using tmp = await tmpdir() @@ -24,8 +26,6 @@ describe("project.initGit endpoint", () => { const fn = (evt: { directory?: string; payload: { type: string } }) => { seen.push(evt) } - const reload = Instance.reload - const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) GlobalBus.on("event", fn) try { @@ -42,10 +42,8 @@ describe("project.initGit endpoint", () => { vcs: "git", worktree: tmp.path, }) - expect(reloadSpy).toHaveBeenCalledTimes(1) - expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe( - true, - ) + // Reload behavior: bus emits exactly one server.instance.disposed for the directory. + expect(disposedEvents(seen, tmp.path)).toBe(1) expect(await Filesystem.exists(path.join(tmp.path, ".git", "opencode"))).toBe(false) const current = await app.request("/project/current", { @@ -70,7 +68,6 @@ describe("project.initGit endpoint", () => { ).toBeTruthy() } finally { await disposeAllInstances() - reloadSpy.mockRestore() GlobalBus.off("event", fn) } }) @@ -82,8 +79,6 @@ describe("project.initGit endpoint", () => { const fn = (evt: { directory?: string; payload: { type: string } }) => { seen.push(evt) } - const reload = Instance.reload - const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) GlobalBus.on("event", fn) try { @@ -98,10 +93,7 @@ describe("project.initGit endpoint", () => { vcs: "git", worktree: tmp.path, }) - expect( - seen.filter((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed").length, - ).toBe(0) - expect(reloadSpy).toHaveBeenCalledTimes(0) + expect(disposedEvents(seen, tmp.path)).toBe(0) const current = await app.request("/project/current", { headers: { @@ -115,7 +107,6 @@ describe("project.initGit endpoint", () => { }) } finally { await disposeAllInstances() - reloadSpy.mockRestore() GlobalBus.off("event", fn) } }) From 96061222d28c64ce6891755c2d91d0783ff42283 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 15:45:21 +0000 Subject: [PATCH 030/178] chore: generate --- packages/opencode/test/permission/next.test.ts | 4 +++- packages/opencode/test/project/worktree.test.ts | 12 ++++++++++-- packages/opencode/test/server/httpapi-mcp.test.ts | 4 +++- .../opencode/test/server/httpapi-provider.test.ts | 4 +++- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 5a0c474021..c615e55e5e 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -999,7 +999,9 @@ it.live("pending permission rejects on instance dispose", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) })) + yield* Effect.promise(() => + Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) }), + ) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 3593c3ba00..806c47615b 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -137,7 +137,11 @@ describe("Worktree", () => { expect(props.name).toBe(info.name) expect(props.branch).toBe(info.branch) - yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)))) + yield* Effect.promise(() => + InstanceStore.runtime.runPromise((s) => + s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), + ), + ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), @@ -157,7 +161,11 @@ describe("Worktree", () => { expect(info.branch).toBe("opencode/test-workspace") yield* Effect.promise(() => ready) - yield* Effect.promise(() => InstanceStore.runtime.runPromise((s) => s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)))) + yield* Effect.promise(() => + InstanceStore.runtime.runPromise((s) => + s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), + ), + ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) }), diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 32f6343ad8..6f2b4cee38 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -58,7 +58,9 @@ function withMcpProject(self: (dir: string) => Effect.Effect) }), ) yield* Effect.addFinalizer(() => - Effect.promise(() => Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) })).pipe(Effect.ignore), + Effect.promise(() => + Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + ).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 5714f719a5..b4cec9115f 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -90,7 +90,9 @@ function withProviderProject(self: (dir: string) => Effect.Effect - Effect.promise(() => Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) })).pipe(Effect.ignore), + Effect.promise(() => + Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + ).pipe(Effect.ignore), ) return yield* self(dir).pipe(provideInstance(dir)) From 1ea6e6cd4b6d041d1ac6c26a4faf3da93ede9408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 2 May 2026 17:49:51 +0200 Subject: [PATCH 031/178] fix(nix): remove stale packages/shared filter (#24930) --- nix/node_modules.nix | 1 - 1 file changed, 1 deletion(-) diff --git a/nix/node_modules.nix b/nix/node_modules.nix index ba97405df9..e10e85d2fe 100644 --- a/nix/node_modules.nix +++ b/nix/node_modules.nix @@ -55,7 +55,6 @@ stdenvNoCC.mkDerivation { --filter './packages/opencode' \ --filter './packages/desktop' \ --filter './packages/app' \ - --filter './packages/shared' \ --frozen-lockfile \ --ignore-scripts \ --no-progress From 0d0ec7dc4663cd0319351443fed4d981001724c6 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sat, 2 May 2026 18:07:22 +0200 Subject: [PATCH 032/178] docs: CLI docs for current commands and flags (#25399) --- packages/web/src/content/docs/ar/cli.mdx | 165 ++++++------ packages/web/src/content/docs/bs/cli.mdx | 165 ++++++------ packages/web/src/content/docs/cli.mdx | 282 ++++++++++++++------ packages/web/src/content/docs/da/cli.mdx | 165 ++++++------ packages/web/src/content/docs/de/cli.mdx | 165 ++++++------ packages/web/src/content/docs/es/cli.mdx | 165 ++++++------ packages/web/src/content/docs/fr/cli.mdx | 165 ++++++------ packages/web/src/content/docs/it/cli.mdx | 165 ++++++------ packages/web/src/content/docs/ja/cli.mdx | 165 ++++++------ packages/web/src/content/docs/ko/cli.mdx | 165 ++++++------ packages/web/src/content/docs/nb/cli.mdx | 165 ++++++------ packages/web/src/content/docs/pl/cli.mdx | 165 ++++++------ packages/web/src/content/docs/pt-br/cli.mdx | 165 ++++++------ packages/web/src/content/docs/ru/cli.mdx | 165 ++++++------ packages/web/src/content/docs/th/cli.mdx | 167 ++++++------ packages/web/src/content/docs/tr/cli.mdx | 165 ++++++------ packages/web/src/content/docs/zh-cn/cli.mdx | 165 ++++++------ packages/web/src/content/docs/zh-tw/cli.mdx | 165 ++++++------ 18 files changed, 1676 insertions(+), 1413 deletions(-) diff --git a/packages/web/src/content/docs/ar/cli.mdx b/packages/web/src/content/docs/ar/cli.mdx index ab2c12fb20..8a9729436e 100644 --- a/packages/web/src/content/docs/ar/cli.mdx +++ b/packages/web/src/content/docs/ar/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### الخيارات -| الخيار | المختصر | الوصف | -| ------------ | ------- | ----------------------------------------------------------------- | -| `--continue` | `-c` | متابعة الجلسة الأخيرة | -| `--session` | `-s` | معرّف الجلسة للمتابعة | -| `--fork` | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | -| `--prompt` | | الموجّه المراد استخدامه | -| `--model` | `-m` | النموذج المراد استخدامه بصيغة provider/model | -| `--agent` | | الوكيل المراد استخدامه | -| `--port` | | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | | اسم المضيف الذي يتم الاستماع عليه | +| الخيار | المختصر | الوصف | +| ---------------------------------------- | ------- | ----------------------------------------------------------------- | +| {"--continue"} | `-c` | متابعة الجلسة الأخيرة | +| {"--session"} | `-s` | معرّف الجلسة للمتابعة | +| {"--fork"} | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | +| {"--prompt"} | | الموجّه المراد استخدامه | +| {"--model"} | `-m` | النموذج المراد استخدامه بصيغة provider/model | +| {"--agent"} | | الوكيل المراد استخدامه | +| {"--port"} | | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | | اسم المضيف الذي يتم الاستماع عليه | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### الرايات -| الراية | المختصر | الوصف | -| ----------- | ------- | ----------------------------------- | -| `--dir` | | دليل العمل الذي ستبدأ منه واجهة TUI | -| `--session` | `-s` | معرّف الجلسة للمتابعة | +| الراية | المختصر | الوصف | +| ---------------------------------------- | ------- | --------------------------------------------------------------------------------- | +| {"--dir"} | | دليل العمل الذي ستبدأ منه واجهة TUI | +| {"--continue"} | `-c` | متابعة آخر جلسة | +| {"--session"} | `-s` | معرّف الجلسة للمتابعة | +| {"--fork"} | | تفريع الجلسة عند المتابعة (استخدمه مع `--continue` أو `--session`) | +| {"--password"} | `-p` | كلمة مرور المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | اسم مستخدم المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_USERNAME` أو `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### الرايات -| الراية | الوصف | -| --------- | ------------------------------------ | -| `--event` | حدث GitHub مُحاكى لتشغيل الوكيل عليه | -| `--token` | رمز وصول شخصي لـ GitHub | +| الراية | الوصف | +| ------------------------------------- | ------------------------------------ | +| {"--event"} | حدث GitHub مُحاكى لتشغيل الوكيل عليه | +| {"--token"} | رمز وصول شخصي لـ GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### الرايات -| الراية | الوصف | -| ----------- | ------------------------------------------------------------- | -| `--refresh` | تحديث ذاكرة التخزين المؤقت للنماذج من models.dev | -| `--verbose` | استخدام مخرجات أكثر تفصيلا للنماذج (تشمل بيانات مثل التكاليف) | +| الراية | الوصف | +| --------------------------------------- | ------------------------------------------------------------- | +| {"--refresh"} | تحديث ذاكرة التخزين المؤقت للنماذج من models.dev | +| {"--verbose"} | استخدام مخرجات أكثر تفصيلا للنماذج (تشمل بيانات مثل التكاليف) | استخدم الراية `--refresh` لتحديث قائمة النماذج المخزنة مؤقتا. يفيد ذلك عند إضافة نماذج جديدة إلى مزود وتريد رؤيتها في OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### الرايات -| الراية | المختصر | الوصف | -| ------------ | ------- | ----------------------------------------------------------------- | -| `--command` | | الأمر المراد تشغيله؛ استخدم الرسالة كوسائط | -| `--continue` | `-c` | متابعة الجلسة الأخيرة | -| `--session` | `-s` | معرّف الجلسة للمتابعة | -| `--fork` | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | -| `--share` | | مشاركة الجلسة | -| `--model` | `-m` | النموذج المراد استخدامه بصيغة provider/model | -| `--agent` | | الوكيل المراد استخدامه | -| `--file` | `-f` | ملف/ملفات لإرفاقها بالرسالة | -| `--format` | | التنسيق: default (منسق) أو json (أحداث JSON خام) | -| `--title` | | عنوان للجلسة (يستخدم موجهًا مقتطعًا إن لم تُحدَّد قيمة) | -| `--attach` | | الإرفاق بخادم opencode قيد التشغيل (مثل http://localhost:4096) | -| `--port` | | منفذ الخادم المحلي (الافتراضي منفذ عشوائي) | +| الراية | المختصر | الوصف | +| ---------------------------------------- | ------- | --------------------------------------------------------------------------------- | +| {"--command"} | | الأمر المراد تشغيله؛ استخدم الرسالة كوسائط | +| {"--continue"} | `-c` | متابعة الجلسة الأخيرة | +| {"--session"} | `-s` | معرّف الجلسة للمتابعة | +| {"--fork"} | | تفريع الجلسة عند المتابعة (يستخدم مع `--continue` أو `--session`) | +| {"--share"} | | مشاركة الجلسة | +| {"--model"} | `-m` | النموذج المراد استخدامه بصيغة provider/model | +| {"--agent"} | | الوكيل المراد استخدامه | +| {"--file"} | `-f` | ملف/ملفات لإرفاقها بالرسالة | +| {"--format"} | | التنسيق: default (منسق) أو json (أحداث JSON خام) | +| {"--title"} | | عنوان للجلسة (يستخدم موجهًا مقتطعًا إن لم تُحدَّد قيمة) | +| {"--attach"} | | الإرفاق بخادم opencode قيد التشغيل (مثل http://localhost:4096) | +| {"--password"} | `-p` | كلمة مرور المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | اسم مستخدم المصادقة الأساسية (الافتراضي `OPENCODE_SERVER_USERNAME` أو `opencode`) | +| {"--dir"} | | دليل التشغيل، أو المسار على الخادم البعيد عند الإرفاق | +| {"--variant"} | | متغير النموذج (جهد الاستدلال الخاص بالمزود) | +| {"--thinking"} | | عرض كتل التفكير | +| {"--port"} | | منفذ الخادم المحلي (الافتراضي منفذ عشوائي) | --- @@ -364,12 +373,12 @@ opencode serve #### الرايات -| الراية | الوصف | -| ------------ | ----------------------------------------- | -| `--port` | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | اسم المضيف الذي يتم الاستماع عليه | -| `--mdns` | تفعيل اكتشاف mDNS | -| `--cors` | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | +| الراية | الوصف | +| ---------------------------------------- | ----------------------------------------- | +| {"--port"} | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | اسم المضيف الذي يتم الاستماع عليه | +| {"--mdns"} | تفعيل اكتشاف mDNS | +| {"--cors"} | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### الرايات -| الراية | المختصر | الوصف | -| ------------- | ------- | ------------------------------------- | -| `--max-count` | `-n` | حصر النتائج في أحدث N جلسات | -| `--format` | | تنسيق المخرجات: table أو json (table) | +| الراية | المختصر | الوصف | +| ----------------------------------------- | ------- | ------------------------------------- | +| {"--max-count"} | `-n` | حصر النتائج في أحدث N جلسات | +| {"--format"} | | تنسيق المخرجات: table أو json (table) | --- @@ -410,12 +419,12 @@ opencode stats #### الرايات -| الراية | الوصف | -| ----------- | ------------------------------------------------------------------------- | -| `--days` | عرض الإحصاءات لآخر N يومًا (الافتراضي: كل الوقت) | -| `--tools` | عدد الأدوات المطلوب عرضها (الافتراضي: الكل) | -| `--models` | عرض تفصيل استخدام النماذج (مخفي افتراضيا). مرّر رقمًا لعرض أعلى N | -| `--project` | التصفية حسب المشروع (الافتراضي: كل المشاريع، سلسلة فارغة: المشروع الحالي) | +| الراية | الوصف | +| --------------------------------------- | ------------------------------------------------------------------------- | +| {"--days"} | عرض الإحصاءات لآخر N يومًا (الافتراضي: كل الوقت) | +| {"--tools"} | عدد الأدوات المطلوب عرضها (الافتراضي: الكل) | +| {"--models"} | عرض تفصيل استخدام النماذج (مخفي افتراضيا). مرّر رقمًا لعرض أعلى N | +| {"--project"} | التصفية حسب المشروع (الافتراضي: كل المشاريع، سلسلة فارغة: المشروع الحالي) | --- @@ -460,12 +469,12 @@ opencode web #### الرايات -| الراية | الوصف | -| ------------ | ----------------------------------------- | -| `--port` | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | اسم المضيف الذي يتم الاستماع عليه | -| `--mdns` | تفعيل اكتشاف mDNS | -| `--cors` | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | +| الراية | الوصف | +| ---------------------------------------- | ----------------------------------------- | +| {"--port"} | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | اسم المضيف الذي يتم الاستماع عليه | +| {"--mdns"} | تفعيل اكتشاف mDNS | +| {"--cors"} | أصول/منشأات إضافية للمتصفح للسماح بـ CORS | --- @@ -481,11 +490,11 @@ opencode acp #### الرايات -| الراية | الوصف | -| ------------ | --------------------------------- | -| `--cwd` | دليل العمل | -| `--port` | المنفذ الذي يتم الاستماع عليه | -| `--hostname` | اسم المضيف الذي يتم الاستماع عليه | +| الراية | الوصف | +| ---------------------------------------- | --------------------------------- | +| {"--cwd"} | دليل العمل | +| {"--port"} | المنفذ الذي يتم الاستماع عليه | +| {"--hostname"} | اسم المضيف الذي يتم الاستماع عليه | --- @@ -499,12 +508,12 @@ opencode uninstall #### الرايات -| الراية | المختصر | الوصف | -| --------------- | ------- | ----------------------------------- | -| `--keep-config` | `-c` | الإبقاء على ملفات التهيئة | -| `--keep-data` | `-d` | الإبقاء على بيانات الجلسات واللقطات | -| `--dry-run` | | عرض ما سيتم حذفه دون تنفيذ الحذف | -| `--force` | `-f` | تخطي مطالبات التأكيد | +| الراية | المختصر | الوصف | +| ------------------------------------------- | ------- | ----------------------------------- | +| {"--keep-config"} | `-c` | الإبقاء على ملفات التهيئة | +| {"--keep-data"} | `-d` | الإبقاء على بيانات الجلسات واللقطات | +| {"--dry-run"} | | عرض ما سيتم حذفه دون تنفيذ الحذف | +| {"--force"} | `-f` | تخطي مطالبات التأكيد | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### الرايات -| الراية | المختصر | الوصف | -| ---------- | ------- | ----------------------------------------------------------- | -| `--method` | `-m` | طريقة التثبيت المستخدمة: curl أو npm أو pnpm أو bun أو brew | +| الراية | المختصر | الوصف | +| -------------------------------------- | ------- | ----------------------------------------------------------- | +| {"--method"} | `-m` | طريقة التثبيت المستخدمة: curl أو npm أو pnpm أو bun أو brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 يدعم سطر أوامر opencode الخيارات العامة التالية. -| الراية | المختصر | الوصف | -| -------------- | ------- | -------------------------------------- | -| `--help` | `-h` | عرض المساعدة | -| `--version` | `-v` | طباعة رقم الإصدار | -| `--print-logs` | | طباعة السجلات إلى stderr | -| `--log-level` | | مستوى السجل (DEBUG, INFO, WARN, ERROR) | +| الراية | المختصر | الوصف | +| ------------------------------------------ | ------- | -------------------------------------- | +| {"--help"} | `-h` | عرض المساعدة | +| {"--version"} | `-v` | طباعة رقم الإصدار | +| {"--print-logs"} | | طباعة السجلات إلى stderr | +| {"--log-level"} | | مستوى السجل (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/bs/cli.mdx b/packages/web/src/content/docs/bs/cli.mdx index 118b81ba4e..c7944e7cf6 100644 --- a/packages/web/src/content/docs/bs/cli.mdx +++ b/packages/web/src/content/docs/bs/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Opcije -| Opcija | Kratko | Opis | -| ------------ | ------ | ------------------------------------------------------------------------ | -| `--continue` | `-c` | Nastavite posljednju sesiju | -| `--session` | `-s` | ID sesije za nastavak | -| `--fork` | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | -| `--prompt` | | Prompt za upotrebu | -| `--model` | `-m` | Model za korištenje u obliku provider/model | -| `--agent` | | Agent za korištenje | -| `--port` | | Port na kojem treba slušati | -| `--hostname` | | Hostname na kojem treba slušati | +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | ------------------------------------------------------------------------ | +| {"--continue"} | `-c` | Nastavite posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | +| {"--prompt"} | | Prompt za upotrebu | +| {"--model"} | `-m` | Model za korištenje u obliku provider/model | +| {"--agent"} | | Agent za korištenje | +| {"--port"} | | Port na kojem treba slušati | +| {"--hostname"} | | Hostname na kojem treba slušati | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Opcije -| Opcija | Kratko | Opis | -| ----------- | ------ | ------------------------------------ | -| `--dir` | | Radni direktorij za pokretanje TUI-a | -| `--session` | `-s` | ID sesije za nastavak | +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | --------------------------------------------------------------------------------------------- | +| {"--dir"} | | Radni direktorij za pokretanje TUI-a | +| {"--continue"} | `-c` | Nastavi posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkuj sesiju prilikom nastavka (koristite sa `--continue` ili `--session`) | +| {"--password"} | `-p` | Lozinka za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Korisničko ime za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_USERNAME` ili `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Opcije -| Opcija | Opis | -| --------- | -------------------------------------- | -| `--event` | GitHub mock event za pokretanje agenta | -| `--token` | GitHub Personal Access Token | +| Opcija | Opis | +| ------------------------------------- | -------------------------------------- | +| {"--event"} | GitHub mock event za pokretanje agenta | +| {"--token"} | GitHub Personal Access Token | --- @@ -293,10 +297,10 @@ opencode models anthropic #### Opcije -| Opcija | Opis | -| ----------- | ------------------------------------------------------------------------ | -| `--refresh` | Osvježite keš modela sa models.dev | -| `--verbose` | Koristite detaljniji izlaz modela (uključuje metapodatke poput troškova) | +| Opcija | Opis | +| --------------------------------------- | ------------------------------------------------------------------------ | +| {"--refresh"} | Osvježite keš modela sa models.dev | +| {"--verbose"} | Koristite detaljniji izlaz modela (uključuje metapodatke poput troškova) | Koristite `--refresh` zastavicu da ažurirate keširanu listu modela. Ovo je korisno kada su novi modeli dodani provajderu i želite da ih vidite u OpenCode. @@ -332,20 +336,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Opcije -| Opcija | Kratko | Opis | -| ------------ | ------ | ------------------------------------------------------------------------ | -| `--command` | | Naredba za pokretanje, koristite poruku za argumente | -| `--continue` | `-c` | Nastavite posljednju sesiju | -| `--session` | `-s` | ID sesije za nastavak | -| `--fork` | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | -| `--share` | | Podijelite sesiju | -| `--model` | `-m` | Model za korištenje u obliku provider/model | -| `--agent` | | Agent za korištenje | -| `--file` | `-f` | Fajlovi koje treba priložiti poruci | -| `--format` | | Format: default (formatiran) ili json (sirovi JSON događaji) | -| `--title` | | Naslov sesije (koristi skraćeni prompt ako nije navedena vrijednost) | -| `--attach` | | Priključite na pokrenuti OpenCode server (npr. http://localhost:4096) | -| `--port` | | Port za lokalni server (zadano na nasumični port) | +| Opcija | Kratko | Opis | +| ---------------------------------------- | ------ | --------------------------------------------------------------------------------------------- | +| {"--command"} | | Naredba za pokretanje, koristite poruku za argumente | +| {"--continue"} | `-c` | Nastavite posljednju sesiju | +| {"--session"} | `-s` | ID sesije za nastavak | +| {"--fork"} | | Forkujte sesiju pri nastavku (koristiti sa `--continue` ili `--session`) | +| {"--share"} | | Podijelite sesiju | +| {"--model"} | `-m` | Model za korištenje u obliku provider/model | +| {"--agent"} | | Agent za korištenje | +| {"--file"} | `-f` | Fajlovi koje treba priložiti poruci | +| {"--format"} | | Format: default (formatiran) ili json (sirovi JSON događaji) | +| {"--title"} | | Naslov sesije (koristi skraćeni prompt ako nije navedena vrijednost) | +| {"--attach"} | | Priključite na pokrenuti OpenCode server (npr. http://localhost:4096) | +| {"--password"} | `-p` | Lozinka za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Korisničko ime za osnovnu autentifikaciju (zadano: `OPENCODE_SERVER_USERNAME` ili `opencode`) | +| {"--dir"} | | Direktorij za pokretanje, ili putanja na udaljenom serveru pri spajanju | +| {"--variant"} | | Varijanta modela (napor zaključivanja specifičan za provajdera) | +| {"--thinking"} | | Prikaži blokove razmišljanja | +| {"--port"} | | Port za lokalni server (zadano na nasumični port) | --- @@ -361,12 +370,12 @@ Ovo pokreće HTTP server koji pruža API pristup funkcionalnosti OpenCode-a bez #### Opcije -| Opcija | Opis | -| ------------ | ----------------------------------------------------- | -| `--port` | Port na kojem treba slušati | -| `--hostname` | Hostname na kojem treba slušati | -| `--mdns` | Omogući mDNS otkrivanje | -| `--cors` | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | +| Opcija | Opis | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem treba slušati | +| {"--mdns"} | Omogući mDNS otkrivanje | +| {"--cors"} | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | --- @@ -390,10 +399,10 @@ opencode session list ##### Opcije -| Opcija | Kratko | Opis | -| ------------- | ------ | -------------------------------------- | -| `--max-count` | `-n` | Ograničenje na N najnovijih sesija | -| `--format` | | Izlazni format: table ili json (table) | +| Opcija | Kratko | Opis | +| ----------------------------------------- | ------ | -------------------------------------- | +| {"--max-count"} | `-n` | Ograničenje na N najnovijih sesija | +| {"--format"} | | Izlazni format: table ili json (table) | --- @@ -407,12 +416,12 @@ opencode stats #### Opcije -| Opcija | Opis | -| ----------- | ---------------------------------------------------------------------------------------------------------- | -| `--days` | Prikaži statistiku za zadnjih N dana (sva vremena) | -| `--tools` | Broj alata za prikaz (svi) | -| `--models` | Prikaži raščlambu korištenja modela (skriveno prema zadanim postavkama). Proslijedite broj za prikaz top N | -| `--project` | Filtriraj po projektu (svi projekti, prazan niz: trenutni projekt) | +| Opcija | Opis | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| {"--days"} | Prikaži statistiku za zadnjih N dana (sva vremena) | +| {"--tools"} | Broj alata za prikaz (svi) | +| {"--models"} | Prikaži raščlambu korištenja modela (skriveno prema zadanim postavkama). Proslijedite broj za prikaz top N | +| {"--project"} | Filtriraj po projektu (svi projekti, prazan niz: trenutni projekt) | --- @@ -457,12 +466,12 @@ Ovo pokreće HTTP server i otvara web pretraživač za pristup OpenCode-u preko #### Opcije -| Opcija | Opis | -| ------------ | ----------------------------------------------------- | -| `--port` | Port na kojem treba slušati | -| `--hostname` | Hostname na kojem treba slušati | -| `--mdns` | Omogući mDNS otkrivanje | -| `--cors` | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | +| Opcija | Opis | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem treba slušati | +| {"--mdns"} | Omogući mDNS otkrivanje | +| {"--cors"} | Dodatni origin(i) pretraživača koji dozvoljavaju CORS | --- @@ -478,11 +487,11 @@ Ova naredba pokreće ACP server koji komunicira preko stdin/stdout koristeći nd #### Opcije -| Opcija | Opis | -| ------------ | --------------------------- | -| `--cwd` | Radni direktorij | -| `--port` | Port na kojem treba slušati | -| `--hostname` | Hostname na kojem slušati | +| Opcija | Opis | +| ---------------------------------------- | --------------------------- | +| {"--cwd"} | Radni direktorij | +| {"--port"} | Port na kojem treba slušati | +| {"--hostname"} | Hostname na kojem slušati | --- @@ -496,12 +505,12 @@ opencode uninstall #### Opcije -| Opcija | Kratko | Opis | -| --------------- | ------ | --------------------------------------------- | -| `--keep-config` | `-c` | Sačuvajte konfiguracijske datoteke | -| `--keep-data` | `-d` | Sačuvajte podatke i snimke sesije | -| `--dry-run` | | Pokažite šta bi bilo uklonjeno bez uklanjanja | -| `--force` | `-f` | Preskoči upite za potvrdu | +| Opcija | Kratko | Opis | +| ------------------------------------------- | ------ | --------------------------------------------- | +| {"--keep-config"} | `-c` | Sačuvajte konfiguracijske datoteke | +| {"--keep-data"} | `-d` | Sačuvajte podatke i snimke sesije | +| {"--dry-run"} | | Pokažite šta bi bilo uklonjeno bez uklanjanja | +| {"--force"} | `-f` | Preskoči upite za potvrdu | --- @@ -527,9 +536,9 @@ opencode upgrade v0.1.48 #### Opcije -| Opcija | Kratko | Opis | -| ---------- | ------ | ------------------------------------------------------- | -| `--method` | `-m` | Korišteni način instalacije; curl, npm, pnpm, bun, brew | +| Opcija | Kratko | Opis | +| -------------------------------------- | ------ | ------------------------------------------------------- | +| {"--method"} | `-m` | Korišteni način instalacije; curl, npm, pnpm, bun, brew | --- @@ -537,12 +546,12 @@ opencode upgrade v0.1.48 OpenCode CLI prihvata sljedeće globalne zastavice. -| Opcija | Kratko | Opis | -| -------------- | ------ | ----------------------------------------- | -| `--help` | `-h` | Prikaži pomoć | -| `--version` | `-v` | Ispiši broj verzije | -| `--print-logs` | | Ispis logova u stderr | -| `--log-level` | | Nivo logovanja (DEBUG, INFO, WARN, ERROR) | +| Opcija | Kratko | Opis | +| ------------------------------------------ | ------ | ----------------------------------------- | +| {"--help"} | `-h` | Prikaži pomoć | +| {"--version"} | `-v` | Ispiši broj verzije | +| {"--print-logs"} | | Ispis logova u stderr | +| {"--log-level"} | | Nivo logovanja (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 7249f4dc90..8ecb6a6eb9 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -29,16 +29,19 @@ opencode [project] #### Flags -| Flag | Short | Description | -| ------------ | ----- | ----------------------------------------------------------------------- | -| `--continue` | `-c` | Continue the last session | -| `--session` | `-s` | Session ID to continue | -| `--fork` | | Fork the session when continuing (use with `--continue` or `--session`) | -| `--prompt` | | Prompt to use | -| `--model` | `-m` | Model to use in the form of provider/model | -| `--agent` | | Agent to use | -| `--port` | | Port to listen on | -| `--hostname` | | Hostname to listen on | +| Flag | Short | Description | +| ------------------------------------------- | ----- | ----------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continue the last session | +| {"--session"} | `-s` | Session ID to continue | +| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | +| {"--prompt"} | | Prompt to use | +| {"--model"} | `-m` | Model to use in the form of provider/model | +| {"--agent"} | | Agent to use | +| {"--port"} | | Port to listen on | +| {"--hostname"} | | Hostname to listen on | +| {"--mdns"} | | Enable mDNS discovery | +| {"--mdns-domain"} | | Custom mDNS domain name | +| {"--cors"} | | Additional browser origin(s) to allow CORS | --- @@ -78,10 +81,14 @@ opencode attach http://10.20.30.40:4096 #### Flags -| Flag | Short | Description | -| ----------- | ----- | --------------------------------- | -| `--dir` | | Working directory to start TUI in | -| `--session` | `-s` | Session ID to continue | +| Flag | Short | Description | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------- | +| {"--dir"} | | Working directory to start TUI in | +| {"--continue"} | `-c` | Continue the last session | +| {"--session"} | `-s` | Session ID to continue | +| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | +| {"--password"} | `-p` | Basic auth password (defaults to `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic auth username (defaults to `OPENCODE_SERVER_USERNAME` or `opencode`) | --- @@ -97,13 +104,13 @@ This command will guide you through creating a new agent with a custom system pr #### Flags -| Flag | Description | -| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--path` | Directory to write the agent file to (defaults to global or `.opencode/agent` based on the prompt) | -| `--description` | What the agent should do | -| `--mode` | Agent mode: `all`, `primary`, or `subagent` | -| `--permissions` | Comma-separated list of permissions to allow (default: all). Available: `bash`, `read`, `edit`, `glob`, `grep`, `webfetch`, `task`, `todowrite`, `websearch`, `lsp`, `skill`. Anything omitted is denied. Alias: `--tools` | -| `--model`, `-m` | Model to use, in `provider/model` format | +| Flag | Short | Description | +| ------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| {"--path"} | | Directory to write the agent file to (defaults to global or `.opencode/agent` based on the prompt) | +| {"--description"} | | What the agent should do | +| {"--mode"} | | Agent mode: `all`, `primary`, or `subagent` | +| {"--permissions"} | | Comma-separated list of permissions to allow (default: all). Available: `bash`, `read`, `edit`, `glob`, `grep`, `webfetch`, `task`, `todowrite`, `websearch`, `lsp`, `skill`. Anything omitted is denied. Alias: `--tools` | +| {"--model"} | `-m` | Model to use, in `provider/model` format | Passing all of `--path`, `--description`, `--mode`, and `--permissions` runs the command non-interactively. @@ -139,6 +146,13 @@ opencode auth login When OpenCode starts up it loads the providers from the credentials file. And if there are any keys defined in your environments or a `.env` file in your project. +##### Flags + +| Flag | Short | Description | +| ---------------------------------------- | ----- | ---------------------------------------------------- | +| {"--provider"} | `-p` | Provider ID or name to log in to | +| {"--method"} | `-m` | Login method label to use, skipping method selection | + --- #### list @@ -199,10 +213,10 @@ opencode github run ##### Flags -| Flag | Description | -| --------- | -------------------------------------- | -| `--event` | GitHub mock event to run the agent for | -| `--token` | GitHub personal access token | +| Flag | Description | +| ------------------------------------- | -------------------------------------- | +| {"--event"} | GitHub mock event to run the agent for | +| {"--token"} | GitHub personal access token | --- @@ -308,10 +322,10 @@ opencode models anthropic #### Flags -| Flag | Description | -| ----------- | ------------------------------------------------------------ | -| `--refresh` | Refresh the models cache from models.dev | -| `--verbose` | Use more verbose model output (includes metadata like costs) | +| Flag | Description | +| --------------------------------------- | ------------------------------------------------------------ | +| {"--refresh"} | Refresh the models cache from models.dev | +| {"--verbose"} | Use more verbose model output (includes metadata like costs) | Use the `--refresh` flag to update the cached model list. This is useful when new models have been added to a provider and you want to see them in OpenCode. @@ -347,21 +361,26 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flags -| Flag | Short | Description | -| -------------------------------- | ----- | ----------------------------------------------------------------------- | -| `--command` | | The command to run, use message for args | -| `--continue` | `-c` | Continue the last session | -| `--session` | `-s` | Session ID to continue | -| `--fork` | | Fork the session when continuing (use with `--continue` or `--session`) | -| `--share` | | Share the session | -| `--model` | `-m` | Model to use in the form of provider/model | -| `--agent` | | Agent to use | -| `--file` | `-f` | File(s) to attach to message | -| `--format` | | Format: default (formatted) or json (raw JSON events) | -| `--title` | | Title for the session (uses truncated prompt if no value provided) | -| `--attach` | | Attach to a running opencode server (e.g., http://localhost:4096) | -| `--port` | | Port for the local server (defaults to random port) | -| `--dangerously-skip-permissions` | | Auto-approve permissions that are not explicitly denied (dangerous!) | +| Flag | Short | Description | +| ------------------------------------------------------------ | ----- | -------------------------------------------------------------------------- | +| {"--command"} | | The command to run, use message for args | +| {"--continue"} | `-c` | Continue the last session | +| {"--session"} | `-s` | Session ID to continue | +| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | +| {"--share"} | | Share the session | +| {"--model"} | `-m` | Model to use in the form of provider/model | +| {"--agent"} | | Agent to use | +| {"--file"} | `-f` | File(s) to attach to message | +| {"--format"} | | Format: default (formatted) or json (raw JSON events) | +| {"--title"} | | Title for the session (uses truncated prompt if no value provided) | +| {"--attach"} | | Attach to a running opencode server (e.g., http://localhost:4096) | +| {"--password"} | `-p` | Basic auth password (defaults to `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic auth username (defaults to `OPENCODE_SERVER_USERNAME` or `opencode`) | +| {"--dir"} | | Directory to run in, or path on the remote server when attaching | +| {"--port"} | | Port for the local server (defaults to random port) | +| {"--variant"} | | Model variant (provider-specific reasoning effort) | +| {"--thinking"} | | Show thinking blocks | +| {"--dangerously-skip-permissions"} | | Auto-approve permissions that are not explicitly denied (dangerous!) | --- @@ -377,12 +396,13 @@ This starts an HTTP server that provides API access to opencode functionality wi #### Flags -| Flag | Description | -| ------------ | ------------------------------------------ | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | -| `--mdns` | Enable mDNS discovery | -| `--cors` | Additional browser origin(s) to allow CORS | +| Flag | Description | +| ------------------------------------------- | ------------------------------------------ | +| {"--port"} | Port to listen on | +| {"--hostname"} | Hostname to listen on | +| {"--mdns"} | Enable mDNS discovery | +| {"--mdns-domain"} | Custom mDNS domain name | +| {"--cors"} | Additional browser origin(s) to allow CORS | --- @@ -406,10 +426,20 @@ opencode session list ##### Flags -| Flag | Short | Description | -| ------------- | ----- | ------------------------------------ | -| `--max-count` | `-n` | Limit to N most recent sessions | -| `--format` | | Output format: table or json (table) | +| Flag | Short | Description | +| ----------------------------------------- | ----- | ------------------------------------ | +| {"--max-count"} | `-n` | Limit to N most recent sessions | +| {"--format"} | | Output format: table or json (table) | + +--- + +#### delete + +Delete an OpenCode session. + +```bash +opencode session delete +``` --- @@ -423,12 +453,12 @@ opencode stats #### Flags -| Flag | Description | -| ----------- | --------------------------------------------------------------------------- | -| `--days` | Show stats for the last N days (all time) | -| `--tools` | Number of tools to show (all) | -| `--models` | Show model usage breakdown (hidden by default). Pass a number to show top N | -| `--project` | Filter by project (all projects, empty string: current project) | +| Flag | Description | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--days"} | Show stats for the last N days (all time) | +| {"--tools"} | Number of tools to show (all) | +| {"--models"} | Show model usage breakdown (hidden by default). Pass a number to show top N | +| {"--project"} | Filter by project (all projects, empty string: current project) | --- @@ -442,6 +472,12 @@ opencode export [sessionID] If you don't provide a session ID, you'll be prompted to select from available sessions. +#### Flags + +| Flag | Description | +| ---------------------------------------- | ------------------------------------- | +| {"--sanitize"} | Redact sensitive transcript/file data | + --- ### import @@ -473,12 +509,13 @@ This starts an HTTP server and opens a web browser to access OpenCode through a #### Flags -| Flag | Description | -| ------------ | ------------------------------------------ | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | -| `--mdns` | Enable mDNS discovery | -| `--cors` | Additional browser origin(s) to allow CORS | +| Flag | Description | +| ------------------------------------------- | ------------------------------------------ | +| {"--port"} | Port to listen on | +| {"--hostname"} | Hostname to listen on | +| {"--mdns"} | Enable mDNS discovery | +| {"--mdns-domain"} | Custom mDNS domain name | +| {"--cors"} | Additional browser origin(s) to allow CORS | --- @@ -494,11 +531,83 @@ This command starts an ACP server that communicates via stdin/stdout using nd-JS #### Flags -| Flag | Description | -| ------------ | --------------------- | -| `--cwd` | Working directory | -| `--port` | Port to listen on | -| `--hostname` | Hostname to listen on | +| Flag | Description | +| ------------------------------------------- | ------------------------------------------ | +| {"--cwd"} | Working directory | +| {"--port"} | Port to listen on | +| {"--hostname"} | Hostname to listen on | +| {"--mdns"} | Enable mDNS discovery | +| {"--mdns-domain"} | Custom mDNS domain name | +| {"--cors"} | Additional browser origin(s) to allow CORS | + +--- + +### plugin + +Install a plugin and update your config. + +```bash +opencode plugin +``` + +Or use the alias. + +```bash +opencode plug +``` + +#### Flags + +| Flag | Short | Description | +| -------------------------------------- | ----- | ------------------------------- | +| {"--global"} | `-g` | Install in global config | +| {"--force"} | `-f` | Replace existing plugin version | + +--- + +### pr + +Fetch and checkout a GitHub PR branch, then run OpenCode. + +```bash +opencode pr +``` + +--- + +### db + +Database tools. + +```bash +opencode db [query] +``` + +#### Flags + +| Flag | Description | +| -------------------------------------- | ------------------------------ | +| {"--format"} | Output format: `json` or `tsv` | + +--- + +#### path + +Print the database path. + +```bash +opencode db path +``` + +--- + +### debug + +Debugging and troubleshooting tools. + +```bash +opencode debug [command] +``` --- @@ -512,12 +621,12 @@ opencode uninstall #### Flags -| Flag | Short | Description | -| --------------- | ----- | ------------------------------------------- | -| `--keep-config` | `-c` | Keep configuration files | -| `--keep-data` | `-d` | Keep session data and snapshots | -| `--dry-run` | | Show what would be removed without removing | -| `--force` | `-f` | Skip confirmation prompts | +| Flag | Short | Description | +| ------------------------------------------- | ----- | ------------------------------------------- | +| {"--keep-config"} | `-c` | Keep configuration files | +| {"--keep-data"} | `-d` | Keep session data and snapshots | +| {"--dry-run"} | | Show what would be removed without removing | +| {"--force"} | `-f` | Skip confirmation prompts | --- @@ -543,9 +652,9 @@ opencode upgrade v0.1.48 #### Flags -| Flag | Short | Description | -| ---------- | ----- | ----------------------------------------------------------------- | -| `--method` | `-m` | The installation method that was used; curl, npm, pnpm, bun, brew | +| Flag | Short | Description | +| -------------------------------------- | ----- | ----------------------------------------------------------------- | +| {"--method"} | `-m` | The installation method that was used; curl, npm, pnpm, bun, brew | --- @@ -553,12 +662,13 @@ opencode upgrade v0.1.48 The opencode CLI takes the following global flags. -| Flag | Short | Description | -| -------------- | ----- | ------------------------------------ | -| `--help` | `-h` | Display help | -| `--version` | `-v` | Print version number | -| `--print-logs` | | Print logs to stderr | -| `--log-level` | | Log level (DEBUG, INFO, WARN, ERROR) | +| Flag | Short | Description | +| ------------------------------------------ | ----- | ------------------------------------ | +| {"--help"} | `-h` | Display help | +| {"--version"} | `-v` | Print version number | +| {"--print-logs"} | | Print logs to stderr | +| {"--log-level"} | | Log level (DEBUG, INFO, WARN, ERROR) | +| {"--pure"} | | Run without external plugins | --- diff --git a/packages/web/src/content/docs/da/cli.mdx b/packages/web/src/content/docs/da/cli.mdx index 45c4f08e3f..02b1b49876 100644 --- a/packages/web/src/content/docs/da/cli.mdx +++ b/packages/web/src/content/docs/da/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flag -| Flag | Kort | Beskrivelse | -| ------------ | ---- | ---------------------------------------------------------------------------- | -| `--continue` | `-c` | Fortsæt sidste session | -| `--session` | `-s` | Sessions-id for at fortsætte | -| `--fork` | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | -| `--prompt` | | Spørg om at bruge | -| `--model` | `-m` | Model til brug i form af provider/model | -| `--agent` | | Agent hos bruge | -| `--port` | | Port at lytte på | -| `--hostname` | | Værtsnavn at lytte på | +| Flag | Kort | Beskrivelse | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Fortsæt sidste session | +| {"--session"} | `-s` | Sessions-id for at fortsætte | +| {"--fork"} | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | +| {"--prompt"} | | Spørg om at bruge | +| {"--model"} | `-m` | Model til brug i form af provider/model | +| {"--agent"} | | Agent hos bruge | +| {"--port"} | | Port at lytte på | +| {"--hostname"} | | Værtsnavn at lytte på | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flag -| Flag | Kort | Beskrivelse | -| ----------- | ---- | -------------------------------- | -| `--dir` | | Arbejdsmappe til at starte TUI i | -| `--session` | `-s` | Sessions-id for at fortsætte | +| Flag | Kort | Beskrivelse | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------- | +| {"--dir"} | | Arbejdsmappe til at starte TUI i | +| {"--continue"} | `-c` | Fortsæt den seneste session | +| {"--session"} | `-s` | Sessions-id for at fortsætte | +| {"--fork"} | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | +| {"--password"} | `-p` | Adgangskode til basic auth (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brugernavn til basic auth (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flag -| Flag | Beskrivelse | -| --------- | ---------------------------------------------- | -| `--event` | GitHub mock begivenhed for at køre agenten for | -| `--token` | GitHub personlig adgangstoken | +| Flag | Beskrivelse | +| ------------------------------------- | ---------------------------------------------- | +| {"--event"} | GitHub mock begivenhed for at køre agenten for | +| {"--token"} | GitHub personlig adgangstoken | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flag -| Flag | Beskrivelse | -| ----------- | ----------------------------------------------------------------------- | -| `--refresh` | Opdater modelcachen fra models.dev | -| `--verbose` | Brug mere detaljeret modeloutput (inkluderer metadata som omkostninger) | +| Flag | Beskrivelse | +| --------------------------------------- | ----------------------------------------------------------------------- | +| {"--refresh"} | Opdater modelcachen fra models.dev | +| {"--verbose"} | Brug mere detaljeret modeloutput (inkluderer metadata som omkostninger) | Brug flaget `--refresh` til at opdatere den cachelagrede modelliste. Dette er nyttigt, når nye modeller er blevet tilføjet til en udbyder, og du vil se dem i OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flag -| Flag | Kort | Beskrivelse | -| ------------ | ---- | ----------------------------------------------------------------------------------- | -| `--command` | | Kommandoen til at køre, brug besked til args | -| `--continue` | `-c` | Fortsæt sidste session | -| `--session` | `-s` | Sessions-id for at fortsætte | -| `--fork` | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | -| `--share` | | Del sessionen | -| `--model` | `-m` | Model til brug i form af provider/model | -| `--agent` | | Agent til brug | -| `--file` | `-f` | Fil(er), der skal vedhæftes til meddelelsen | -| `--format` | | Format: standard (formateret) eller json (rå JSON hændelser) | -| `--title` | | Titel for sessionen (bruger trunkeret prompt, hvis der ikke er angivet nogen værdi) | -| `--attach` | | Tilslut til en kørende opencode-server (f.eks. http://localhost:4096) | -| `--port` | | Port til den lokale server (standard til vilkårlig port) | +| Flag | Kort | Beskrivelse | +| ---------------------------------------- | ---- | ----------------------------------------------------------------------------------- | +| {"--command"} | | Kommandoen til at køre, brug besked til args | +| {"--continue"} | `-c` | Fortsæt sidste session | +| {"--session"} | `-s` | Sessions-id for at fortsætte | +| {"--fork"} | | Forgren sessionen ved fortsættelse (brug med `--continue` eller `--session`) | +| {"--share"} | | Del sessionen | +| {"--model"} | `-m` | Model til brug i form af provider/model | +| {"--agent"} | | Agent til brug | +| {"--file"} | `-f` | Fil(er), der skal vedhæftes til meddelelsen | +| {"--format"} | | Format: standard (formateret) eller json (rå JSON hændelser) | +| {"--title"} | | Titel for sessionen (bruger trunkeret prompt, hvis der ikke er angivet nogen værdi) | +| {"--attach"} | | Tilslut til en kørende opencode-server (f.eks. http://localhost:4096) | +| {"--password"} | `-p` | Adgangskode til basic auth (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brugernavn til basic auth (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | +| {"--dir"} | | Mappe at køre i, eller sti på fjernserveren ved tilkobling | +| {"--variant"} | | Modelvariant (udbyder-specifik ræsonneringsindsats) | +| {"--thinking"} | | Vis tænkeblokke | +| {"--port"} | | Port til den lokale server (standard til vilkårlig port) | --- @@ -364,12 +373,12 @@ Dette starter en HTTP-server, der giver API-adgang til opencode-funktionalitet u #### Flag -| Flag | Beskrivelse | -| ------------ | ------------------------------------------------ | -| `--port` | Port at lytte på | -| `--hostname` | Værtsnavn at lytte på | -| `--mdns` | Aktiver mDNS-opdagelse | -| `--cors` | Yderligere browseroprindelse til at tillade CORS | +| Flag | Beskrivelse | +| ---------------------------------------- | ------------------------------------------------ | +| {"--port"} | Port at lytte på | +| {"--hostname"} | Værtsnavn at lytte på | +| {"--mdns"} | Aktiver mDNS-opdagelse | +| {"--cors"} | Yderligere browseroprindelse til at tillade CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flag -| Flag | Kort | Beskrivelse | -| ------------- | ---- | -------------------------------------- | -| `--max-count` | `-n` | Begræns til N seneste sessioner | -| `--format` | | Outputformat: tabel eller json (tabel) | +| Flag | Kort | Beskrivelse | +| ----------------------------------------- | ---- | -------------------------------------- | +| {"--max-count"} | `-n` | Begræns til N seneste sessioner | +| {"--format"} | | Outputformat: tabel eller json (tabel) | --- @@ -410,12 +419,12 @@ opencode stats #### Flag -| Flag | Beskrivelse | -| ----------- | --------------------------------------------------------------------------- | -| `--days` | Vis statistik for de sidste N dage (hele tiden) | -| `--tools` | Antal værktøjer, der skal vises (alle) | -| `--models` | Vis modelbrugsopdeling (skjult som standard). Send et tal for at vise top N | -| `--project` | Filtre efter projekt (alle projekter, tom streng: nuværende projekt) | +| Flag | Beskrivelse | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--days"} | Vis statistik for de sidste N dage (hele tiden) | +| {"--tools"} | Antal værktøjer, der skal vises (alle) | +| {"--models"} | Vis modelbrugsopdeling (skjult som standard). Send et tal for at vise top N | +| {"--project"} | Filtre efter projekt (alle projekter, tom streng: nuværende projekt) | --- @@ -460,12 +469,12 @@ Dette starter en HTTP-server og åbner en webbrowser for at få adgang til OpenC #### Flag -| Flag | Beskrivelse | -| ------------ | ------------------------------------------------ | -| `--port` | Port at lytte på | -| `--hostname` | Værtsnavn at lytte på | -| `--mdns` | Aktiver mDNS-opdagelse | -| `--cors` | Yderligere browseroprindelse til at tillade CORS | +| Flag | Beskrivelse | +| ---------------------------------------- | ------------------------------------------------ | +| {"--port"} | Port at lytte på | +| {"--hostname"} | Værtsnavn at lytte på | +| {"--mdns"} | Aktiver mDNS-opdagelse | +| {"--cors"} | Yderligere browseroprindelse til at tillade CORS | --- @@ -481,11 +490,11 @@ Denne kommando starter en ACP-server, der kommunikerer via stdin/stdout ved hjæ #### Flag -| Flag | Beskrivelse | -| ------------ | --------------------- | -| `--cwd` | Arbejdsmappe | -| `--port` | Port at lytte på | -| `--hostname` | Værtsnavn at lytte på | +| Flag | Beskrivelse | +| ---------------------------------------- | --------------------- | +| {"--cwd"} | Arbejdsmappe | +| {"--port"} | Port at lytte på | +| {"--hostname"} | Værtsnavn at lytte på | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flag -| Flag | Kort | Beskrivelse | -| --------------- | ---- | ------------------------------------------------ | -| `--keep-config` | `-c` | Se konfigurationsfiler | -| `--keep-data` | `-d` | Gem sessionsdata og snapshots | -| `--dry-run` | | Vis, hvad der ville blive fjernet uden at fjerne | -| `--force` | `-f` | Spring bekræftelsesspørgsmål over | +| Flag | Kort | Beskrivelse | +| ------------------------------------------- | ---- | ------------------------------------------------ | +| {"--keep-config"} | `-c` | Se konfigurationsfiler | +| {"--keep-data"} | `-d` | Gem sessionsdata og snapshots | +| {"--dry-run"} | | Vis, hvad der ville blive fjernet uden at fjerne | +| {"--force"} | `-f` | Spring bekræftelsesspørgsmål over | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### upgrade -| Flag | Kort | Beskrivelse | -| ---------- | ---- | ---------------------------------------------------------------- | -| `--method` | `-m` | Installationsmetoden, der blev brugt; curl, npm, pnpm, bun, brew | +| Flag | Kort | Beskrivelse | +| -------------------------------------- | ---- | ---------------------------------------------------------------- | +| {"--method"} | `-m` | Installationsmetoden, der blev brugt; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI tager følgende globale flag. -| Flag | Kort | Beskrivelse | -| -------------- | ---- | ------------------------------------ | -| `--help` | `-h` | Vis hjælp | -| `--version` | `-v` | Udskriftsversionsnummer | -| `--print-logs` | | Udskriv logfiler til stderr | -| `--log-level` | | Logniveau (DEBUG, INFO, WARN, ERROR) | +| Flag | Kort | Beskrivelse | +| ------------------------------------------ | ---- | ------------------------------------ | +| {"--help"} | `-h` | Vis hjælp | +| {"--version"} | `-v` | Udskriftsversionsnummer | +| {"--print-logs"} | | Udskriv logfiler til stderr | +| {"--log-level"} | | Logniveau (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/de/cli.mdx b/packages/web/src/content/docs/de/cli.mdx index 43a1189d60..94e9c88fac 100644 --- a/packages/web/src/content/docs/de/cli.mdx +++ b/packages/web/src/content/docs/de/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Optionen -| Flag | Kurz | Beschreibung | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--continue` | `-c` | Setzen Sie die letzte Sitzung fort | -| `--session` | `-s` | Session-ID zum Fortfahren | -| `--fork` | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session`) | -| `--prompt` | | Prompt zur Verwendung | -| `--model` | `-m` | Zu verwendendes Modell in der Form provider/model | -| `--agent` | | Zu verwendender Agent | -| `--port` | | Port zum Abhören | -| `--hostname` | | Hostname zum Abhören | +| Flag | Kurz | Beschreibung | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------- | +| {"--continue"} | `-c` | Setzen Sie die letzte Sitzung fort | +| {"--session"} | `-s` | Session-ID zum Fortfahren | +| {"--fork"} | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session`) | +| {"--prompt"} | | Prompt zur Verwendung | +| {"--model"} | `-m` | Zu verwendendes Modell in der Form provider/model | +| {"--agent"} | | Zu verwendender Agent | +| {"--port"} | | Port zum Abhören | +| {"--hostname"} | | Hostname zum Abhören | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Optionen -| Flag | Kurz | Beschreibung | -| ----------- | ---- | ----------------------------------------- | -| `--dir` | | Arbeitsverzeichnis zum Starten von TUI in | -| `--session` | `-s` | Session-ID zum Fortfahren | +| Flag | Kurz | Beschreibung | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------------------- | +| {"--dir"} | | Arbeitsverzeichnis zum Starten von TUI in | +| {"--continue"} | `-c` | Letzte Sitzung fortsetzen | +| {"--session"} | `-s` | Session-ID zum Fortfahren | +| {"--fork"} | | Sitzung beim Fortsetzen abzweigen (mit `--continue` oder `--session` verwenden) | +| {"--password"} | `-p` | Basic-Auth-Passwort (standardmäßig `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic-Auth-Benutzername (standardmäßig `OPENCODE_SERVER_USERNAME` oder `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Optionen -| Flag | Beschreibung | -| --------- | --------------------------------------------------- | -| `--event` | GitHub Scheinereignis zum Ausführen des Agenten für | -| `--token` | GitHub persönliches Zugriffstoken | +| Flag | Beschreibung | +| ------------------------------------- | --------------------------------------------------- | +| {"--event"} | GitHub Scheinereignis zum Ausführen des Agenten für | +| {"--token"} | GitHub persönliches Zugriffstoken | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Optionen -| Flag | Beschreibung | -| ----------- | ------------------------------------------------------------------------------------- | -| `--refresh` | Aktualisieren Sie den Modellcache von models.dev | -| `--verbose` | Verwenden Sie eine ausführlichere Modellausgabe (einschließlich Metadaten wie Kosten) | +| Flag | Beschreibung | +| --------------------------------------- | ------------------------------------------------------------------------------------- | +| {"--refresh"} | Aktualisieren Sie den Modellcache von models.dev | +| {"--verbose"} | Verwenden Sie eine ausführlichere Modellausgabe (einschließlich Metadaten wie Kosten) | Verwenden Sie das Flag `--refresh`, um die zwischengespeicherte Modellliste zu aktualisieren. Dies ist nützlich, wenn einem Anbieter neue Modelle hinzugefügt wurden und Sie diese in OpenCode sehen möchten. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Optionen -| Flag | Kurz | Beschreibung | -| ------------ | ---- | --------------------------------------------------------------------------------------------------- | -| `--command` | | Der auszuführende Befehl, Argumente als Nachricht verwenden | -| `--continue` | `-c` | Setzen Sie die letzte Sitzung fort | -| `--session` | `-s` | Session-ID zum Fortfahren | -| `--fork` | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session` verwenden) | -| `--share` | | Teilen Sie die Sitzung | -| `--model` | `-m` | Zu verwendendes Modell in der Form provider/model | -| `--agent` | | Zu verwendender Agent | -| `--file` | `-f` | Datei(en) zum Anhängen an die Nachricht | -| `--format` | | Format: default (formatiert) oder json (rohe JSON-Ereignisse) | -| `--title` | | Titel für die Sitzung (verwendet eine verkürzte Eingabeaufforderung, wenn kein Wert angegeben wird) | -| `--attach` | | An einen laufenden OpenCode-Server anschließen (z.B. http://localhost:4096) | -| `--port` | | Port für den lokalen Server (standardmäßig zufälliger Port) | +| Flag | Kurz | Beschreibung | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------------------------- | +| {"--command"} | | Der auszuführende Befehl, Argumente als Nachricht verwenden | +| {"--continue"} | `-c` | Setzen Sie die letzte Sitzung fort | +| {"--session"} | `-s` | Session-ID zum Fortfahren | +| {"--fork"} | | Sitzung beim Fortsetzen verzweigen (mit `--continue` oder `--session` verwenden) | +| {"--share"} | | Teilen Sie die Sitzung | +| {"--model"} | `-m` | Zu verwendendes Modell in der Form provider/model | +| {"--agent"} | | Zu verwendender Agent | +| {"--file"} | `-f` | Datei(en) zum Anhängen an die Nachricht | +| {"--format"} | | Format: default (formatiert) oder json (rohe JSON-Ereignisse) | +| {"--title"} | | Titel für die Sitzung (verwendet eine verkürzte Eingabeaufforderung, wenn kein Wert angegeben wird) | +| {"--attach"} | | An einen laufenden OpenCode-Server anschließen (z.B. http://localhost:4096) | +| {"--password"} | `-p` | Basic-Auth-Passwort (standardmäßig `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic-Auth-Benutzername (standardmäßig `OPENCODE_SERVER_USERNAME` oder `opencode`) | +| {"--dir"} | | Verzeichnis für die Ausführung, oder Pfad auf dem Remote-Server beim Anhängen | +| {"--variant"} | | Modellvariante (anbieterspezifischer Reasoning-Aufwand) | +| {"--thinking"} | | Denkblöcke anzeigen | +| {"--port"} | | Port für den lokalen Server (standardmäßig zufälliger Port) | --- @@ -364,12 +373,12 @@ Dadurch wird ein HTTP-Server gestartet, der API-Zugriff auf OpenCode-Funktionali #### Optionen -| Flag | Beschreibung | -| ------------ | ----------------------------------------------- | -| `--port` | Port zum Abhören | -| `--hostname` | Hostname zum Abhören | -| `--mdns` | mDNS-Discovery aktivieren | -| `--cors` | Zusätzliche Browser-Ursprünge für CORS zulassen | +| Flag | Beschreibung | +| ---------------------------------------- | ----------------------------------------------- | +| {"--port"} | Port zum Abhören | +| {"--hostname"} | Hostname zum Abhören | +| {"--mdns"} | mDNS-Discovery aktivieren | +| {"--cors"} | Zusätzliche Browser-Ursprünge für CORS zulassen | --- @@ -393,10 +402,10 @@ opencode session list ##### Optionen -| Flag | Kurz | Beschreibung | -| ------------- | ---- | ---------------------------------------- | -| `--max-count` | `-n` | Beschränken auf die N neuesten Sitzungen | -| `--format` | | Ausgabeformat: table oder json (table) | +| Flag | Kurz | Beschreibung | +| ----------------------------------------- | ---- | ---------------------------------------- | +| {"--max-count"} | `-n` | Beschränken auf die N neuesten Sitzungen | +| {"--format"} | | Ausgabeformat: table oder json (table) | --- @@ -410,12 +419,12 @@ opencode stats #### Optionen -| Flag | Beschreibung | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `--days` | Statistiken für die letzten N Tage anzeigen (alle Zeiten) | -| `--tools` | Anzahl der angebotenen Werkzeuge (alle) | -| `--models` | Aufschlüsselung der Modellnutzung anzeigen (standardmäßig ausgeblendet). Übergeben Sie eine Zahl, um die obersten N anzuzeigen | -| `--project` | Nach Projekt filtern (alle Projekte, leere Zeichenfolge: aktuelles Projekt) | +| Flag | Beschreibung | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| {"--days"} | Statistiken für die letzten N Tage anzeigen (alle Zeiten) | +| {"--tools"} | Anzahl der angebotenen Werkzeuge (alle) | +| {"--models"} | Aufschlüsselung der Modellnutzung anzeigen (standardmäßig ausgeblendet). Übergeben Sie eine Zahl, um die obersten N anzuzeigen | +| {"--project"} | Nach Projekt filtern (alle Projekte, leere Zeichenfolge: aktuelles Projekt) | --- @@ -460,12 +469,12 @@ Dadurch wird ein HTTP-Server gestartet und ein Webbrowser geöffnet, um über ei #### Optionen -| Flag | Beschreibung | -| ------------ | ----------------------------------------------- | -| `--port` | Port zum Abhören | -| `--hostname` | Hostname zum Abhören | -| `--mdns` | mDNS-Discovery aktivieren | -| `--cors` | Zusätzliche Browser-Ursprünge für CORS zulassen | +| Flag | Beschreibung | +| ---------------------------------------- | ----------------------------------------------- | +| {"--port"} | Port zum Abhören | +| {"--hostname"} | Hostname zum Abhören | +| {"--mdns"} | mDNS-Discovery aktivieren | +| {"--cors"} | Zusätzliche Browser-Ursprünge für CORS zulassen | --- @@ -481,11 +490,11 @@ Dieser Befehl startet einen ACP-Server, der über stdin/stdout unter Verwendung #### Optionen -| Flag | Beschreibung | -| ------------ | -------------------- | -| `--cwd` | Arbeitsverzeichnis | -| `--port` | Port zum Abhören | -| `--hostname` | Hostname zum Abhören | +| Flag | Beschreibung | +| ---------------------------------------- | -------------------- | +| {"--cwd"} | Arbeitsverzeichnis | +| {"--port"} | Port zum Abhören | +| {"--hostname"} | Hostname zum Abhören | --- @@ -499,12 +508,12 @@ opencode uninstall #### Optionen -| Flag | Kurz | Beschreibung | -| --------------- | ---- | --------------------------------------------------- | -| `--keep-config` | `-c` | Konfigurationsdateien behalten | -| `--keep-data` | `-d` | Sitzungsdaten und Snapshots aufbewahren | -| `--dry-run` | | Zeigt, was entfernt werden würde, ohne zu entfernen | -| `--force` | `-f` | Bestätigungsaufforderungen überspringen | +| Flag | Kurz | Beschreibung | +| ------------------------------------------- | ---- | --------------------------------------------------- | +| {"--keep-config"} | `-c` | Konfigurationsdateien behalten | +| {"--keep-data"} | `-d` | Sitzungsdaten und Snapshots aufbewahren | +| {"--dry-run"} | | Zeigt, was entfernt werden würde, ohne zu entfernen | +| {"--force"} | `-f` | Bestätigungsaufforderungen überspringen | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Optionen -| Flag | Kurz | Beschreibung | -| ---------- | ---- | ------------------------------------------------------------------- | -| `--method` | `-m` | Die zu verwendende Installationsmethode; curl, npm, pnpm, bun, brew | +| Flag | Kurz | Beschreibung | +| -------------------------------------- | ---- | ------------------------------------------------------------------- | +| {"--method"} | `-m` | Die zu verwendende Installationsmethode; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 Der OpenCode CLI akzeptiert die folgenden globalen Flags. -| Flag | Kurz | Beschreibung | -| -------------- | ---- | ----------------------------------------- | -| `--help` | `-h` | Hilfe anzeigen | -| `--version` | `-v` | Versionsnummer drucken | -| `--print-logs` | | Protokolle nach stderr drucken | -| `--log-level` | | Protokollebene (DEBUG, INFO, WARN, ERROR) | +| Flag | Kurz | Beschreibung | +| ------------------------------------------ | ---- | ----------------------------------------- | +| {"--help"} | `-h` | Hilfe anzeigen | +| {"--version"} | `-v` | Versionsnummer drucken | +| {"--print-logs"} | | Protokolle nach stderr drucken | +| {"--log-level"} | | Protokollebene (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/es/cli.mdx b/packages/web/src/content/docs/es/cli.mdx index 5c86474a61..6b66e7b5fc 100644 --- a/packages/web/src/content/docs/es/cli.mdx +++ b/packages/web/src/content/docs/es/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Opciones -| Opción | Corta | Descripción | -| ------------ | ----- | --------------------------------------------------------------------- | -| `--continue` | `-c` | Continuar la última sesión | -| `--session` | `-s` | ID de sesión para continuar | -| `--fork` | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | -| `--prompt` | | Aviso de uso | -| `--model` | `-m` | Modelo a utilizar en forma de proveedor/modelo | -| `--agent` | | Agente a utilizar | -| `--port` | | Puerto para escuchar | -| `--hostname` | | Nombre de host para escuchar | +| Opción | Corta | Descripción | +| ---------------------------------------- | ----- | --------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continuar la última sesión | +| {"--session"} | `-s` | ID de sesión para continuar | +| {"--fork"} | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | +| {"--prompt"} | | Aviso de uso | +| {"--model"} | `-m` | Modelo a utilizar en forma de proveedor/modelo | +| {"--agent"} | | Agente a utilizar | +| {"--port"} | | Puerto para escuchar | +| {"--hostname"} | | Nombre de host para escuchar | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Opciones -| Opción | Corta | Descripción | -| ----------- | ----- | ----------------------------------------- | -| `--dir` | | Directorio de trabajo para iniciar TUI en | -| `--session` | `-s` | ID de sesión para continuar | +| Opción | Corta | Descripción | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------- | +| {"--dir"} | | Directorio de trabajo para iniciar TUI en | +| {"--continue"} | `-c` | Continuar la última sesión | +| {"--session"} | `-s` | ID de sesión para continuar | +| {"--fork"} | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | +| {"--password"} | `-p` | Contraseña de autenticación básica (predeterminada: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuario de autenticación básica (predeterminado: `OPENCODE_SERVER_USERNAME` u `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Opciones -| Opción | Descripción | -| --------- | ---------------------------------------------- | -| `--event` | GitHub evento simulado para ejecutar el agente | -| `--token` | GitHub token de acceso personal | +| Opción | Descripción | +| ------------------------------------- | ---------------------------------------------- | +| {"--event"} | GitHub evento simulado para ejecutar el agente | +| {"--token"} | GitHub token de acceso personal | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Opciones -| Opción | Descripción | -| ----------- | --------------------------------------------------------------------------- | -| `--refresh` | Actualizar la caché de modelos desde models.dev | -| `--verbose` | Utilice una salida del modelo más detallada (incluye metadatos como costos) | +| Opción | Descripción | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--refresh"} | Actualizar la caché de modelos desde models.dev | +| {"--verbose"} | Utilice una salida del modelo más detallada (incluye metadatos como costos) | Utilice el indicador `--refresh` para actualizar la lista de modelos almacenados en caché. Esto es útil cuando se han agregado nuevos modelos a un proveedor y desea verlos en OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Opciones -| Opción | Corta | Descripción | -| ------------ | ----- | ----------------------------------------------------------------------------------- | -| `--command` | | El comando a ejecutar, use mensaje para args | -| `--continue` | `-c` | Continuar la última sesión | -| `--session` | `-s` | ID de sesión para continuar | -| `--fork` | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | -| `--share` | | Comparte la sesión | -| `--model` | `-m` | Modelo a utilizar en forma de proveedor/modelo | -| `--agent` | | Agente a utilizar | -| `--file` | `-f` | Archivo(s) para adjuntar al mensaje | -| `--format` | | Formato: predeterminado (formateado) o json (eventos JSON sin formato) | -| `--title` | | Título de la sesión (utiliza un mensaje truncado si no se proporciona ningún valor) | -| `--attach` | | Adjuntar a un servidor opencode en ejecución (por ejemplo, http://localhost:4096) | -| `--port` | | Puerto para el servidor local (el puerto predeterminado es aleatorio) | +| Opción | Corta | Descripción | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------- | +| {"--command"} | | El comando a ejecutar, use mensaje para args | +| {"--continue"} | `-c` | Continuar la última sesión | +| {"--session"} | `-s` | ID de sesión para continuar | +| {"--fork"} | | Bifurcar la sesión al continuar (usar con `--continue` o `--session`) | +| {"--share"} | | Comparte la sesión | +| {"--model"} | `-m` | Modelo a utilizar en forma de proveedor/modelo | +| {"--agent"} | | Agente a utilizar | +| {"--file"} | `-f` | Archivo(s) para adjuntar al mensaje | +| {"--format"} | | Formato: predeterminado (formateado) o json (eventos JSON sin formato) | +| {"--title"} | | Título de la sesión (utiliza un mensaje truncado si no se proporciona ningún valor) | +| {"--attach"} | | Adjuntar a un servidor opencode en ejecución (por ejemplo, http://localhost:4096) | +| {"--password"} | `-p` | Contraseña de autenticación básica (predeterminada: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuario de autenticación básica (predeterminado: `OPENCODE_SERVER_USERNAME` u `opencode`) | +| {"--dir"} | | Directorio de ejecución, o ruta en el servidor remoto al adjuntar | +| {"--variant"} | | Variante del modelo (esfuerzo de razonamiento específico del proveedor) | +| {"--thinking"} | | Mostrar bloques de pensamiento | +| {"--port"} | | Puerto para el servidor local (el puerto predeterminado es aleatorio) | --- @@ -364,12 +373,12 @@ Esto inicia un servidor HTTP que proporciona acceso API a la funcionalidad openc #### Opciones -| Opción | Descripción | -| ------------ | ---------------------------------------------------- | -| `--port` | Puerto para escuchar | -| `--hostname` | Nombre de host para escuchar | -| `--mdns` | Habilitar el descubrimiento de mDNS | -| `--cors` | Orígenes de navegador adicionales para permitir CORS | +| Opción | Descripción | +| ---------------------------------------- | ---------------------------------------------------- | +| {"--port"} | Puerto para escuchar | +| {"--hostname"} | Nombre de host para escuchar | +| {"--mdns"} | Habilitar el descubrimiento de mDNS | +| {"--cors"} | Orígenes de navegador adicionales para permitir CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Opciones -| Opción | Corta | Descripción | -| ------------- | ----- | --------------------------------------- | -| `--max-count` | `-n` | Limitar a N sesiones más recientes | -| `--format` | | Formato de salida: tabla o json (tabla) | +| Opción | Corta | Descripción | +| ----------------------------------------- | ----- | --------------------------------------- | +| {"--max-count"} | `-n` | Limitar a N sesiones más recientes | +| {"--format"} | | Formato de salida: tabla o json (tabla) | --- @@ -410,12 +419,12 @@ opencode stats #### Opciones -| Opción | Descripción | -| ----------- | ------------------------------------------------------------------------------------------------------------------------ | -| `--days` | Mostrar estadísticas de los últimos N días (todo el tiempo) | -| `--tools` | Número de herramientas para mostrar (todas) | -| `--models` | Mostrar el desglose del uso del modelo (oculto de forma predeterminada). Pase un número para mostrar la parte superior N | -| `--project` | Filtrar por proyecto (todos los proyectos, cadena vacía: proyecto actual) | +| Opción | Descripción | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| {"--days"} | Mostrar estadísticas de los últimos N días (todo el tiempo) | +| {"--tools"} | Número de herramientas para mostrar (todas) | +| {"--models"} | Mostrar el desglose del uso del modelo (oculto de forma predeterminada). Pase un número para mostrar la parte superior N | +| {"--project"} | Filtrar por proyecto (todos los proyectos, cadena vacía: proyecto actual) | --- @@ -460,12 +469,12 @@ Esto inicia un servidor HTTP y abre un navegador web para acceder a OpenCode a t #### Opciones -| Opción | Descripción | -| ------------ | ---------------------------------------------------- | -| `--port` | Puerto para escuchar | -| `--hostname` | Nombre de host para escuchar | -| `--mdns` | Habilitar el descubrimiento de mDNS | -| `--cors` | Orígenes de navegador adicionales para permitir CORS | +| Opción | Descripción | +| ---------------------------------------- | ---------------------------------------------------- | +| {"--port"} | Puerto para escuchar | +| {"--hostname"} | Nombre de host para escuchar | +| {"--mdns"} | Habilitar el descubrimiento de mDNS | +| {"--cors"} | Orígenes de navegador adicionales para permitir CORS | --- @@ -481,11 +490,11 @@ Este comando inicia un servidor ACP que se comunica a través de stdin/stdout us #### Opciones -| Opción | Descripción | -| ------------ | ---------------------------- | -| `--cwd` | Directorio de trabajo | -| `--port` | Puerto para escuchar | -| `--hostname` | Nombre de host para escuchar | +| Opción | Descripción | +| ---------------------------------------- | ---------------------------- | +| {"--cwd"} | Directorio de trabajo | +| {"--port"} | Puerto para escuchar | +| {"--hostname"} | Nombre de host para escuchar | --- @@ -499,12 +508,12 @@ opencode uninstall #### Opciones -| Opción | Corta | Descripción | -| --------------- | ----- | ----------------------------------------- | -| `--keep-config` | `-c` | Mantener archivos de configuración | -| `--keep-data` | `-d` | Conservar datos de sesión e instantáneas | -| `--dry-run` | | Mostrar lo que se eliminaría sin eliminar | -| `--force` | `-f` | Saltar mensajes de confirmación | +| Opción | Corta | Descripción | +| ------------------------------------------- | ----- | ----------------------------------------- | +| {"--keep-config"} | `-c` | Mantener archivos de configuración | +| {"--keep-data"} | `-d` | Conservar datos de sesión e instantáneas | +| {"--dry-run"} | | Mostrar lo que se eliminaría sin eliminar | +| {"--force"} | `-f` | Saltar mensajes de confirmación | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Opciones -| Opción | Corta | Descripción | -| ---------- | ----- | ------------------------------------------------------------------- | -| `--method` | `-m` | El método de instalación que se utilizó; curl, npm, pnpm, bun, brew | +| Opción | Corta | Descripción | +| -------------------------------------- | ----- | ------------------------------------------------------------------- | +| {"--method"} | `-m` | El método de instalación que se utilizó; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 La CLI de OpenCode toma las siguientes banderas globales. -| Opción | Corta | Descripción | -| -------------- | ----- | -------------------------------------------- | -| `--help` | `-h` | Mostrar ayuda | -| `--version` | `-v` | Número de versión de impresión | -| `--print-logs` | | Imprimir registros en stderr | -| `--log-level` | | Nivel de registro (DEBUG, INFO, WARN, ERROR) | +| Opción | Corta | Descripción | +| ------------------------------------------ | ----- | -------------------------------------------- | +| {"--help"} | `-h` | Mostrar ayuda | +| {"--version"} | `-v` | Número de versión de impresión | +| {"--print-logs"} | | Imprimir registros en stderr | +| {"--log-level"} | | Nivel de registro (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/fr/cli.mdx b/packages/web/src/content/docs/fr/cli.mdx index cffa748ad2..c5455654a6 100644 --- a/packages/web/src/content/docs/fr/cli.mdx +++ b/packages/web/src/content/docs/fr/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Options -| Option | Court | Description | -| ------------ | ----- | ----------------------------------------------------------------------------- | -| `--continue` | `-c` | Continuer la dernière session | -| `--session` | `-s` | ID de session pour continuer | -| `--fork` | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | -| `--prompt` | | Prompt à utiliser | -| `--model` | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | -| `--agent` | | Agent à utiliser | -| `--port` | | Port d'écoute | -| `--hostname` | | Nom d'hôte d'écoute | +| Option | Court | Description | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continuer la dernière session | +| {"--session"} | `-s` | ID de session pour continuer | +| {"--fork"} | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | +| {"--prompt"} | | Prompt à utiliser | +| {"--model"} | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | +| {"--agent"} | | Agent à utiliser | +| {"--port"} | | Port d'écoute | +| {"--hostname"} | | Nom d'hôte d'écoute | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Options -| Option | Court | Description | -| ----------- | ----- | ---------------------------------------------- | -| `--dir` | | Répertoire de travail dans lequel démarrer TUI | -| `--session` | `-s` | ID de session pour continuer | +| Option | Court | Description | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------------------------------- | +| {"--dir"} | | Répertoire de travail dans lequel démarrer TUI | +| {"--continue"} | `-c` | Continuer la dernière session | +| {"--session"} | `-s` | ID de session pour continuer | +| {"--fork"} | | Dupliquer la session lors de la reprise (à utiliser avec `--continue` ou `--session`) | +| {"--password"} | `-p` | Mot de passe d'authentification de base (par défaut `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nom d'utilisateur d'authentification de base (par défaut `OPENCODE_SERVER_USERNAME` ou `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Options -| Option | Description | -| --------- | ---------------------------------------------------- | -| `--event` | Événement simulé GitHub pour lequel exécuter l'agent | -| `--token` | Jeton d'accès personnel GitHub | +| Option | Description | +| ------------------------------------- | ---------------------------------------------------- | +| {"--event"} | Événement simulé GitHub pour lequel exécuter l'agent | +| {"--token"} | Jeton d'accès personnel GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Options -| Option | Description | -| ----------- | ------------------------------------------------------------------------------------------ | -| `--refresh` | Actualisez le cache des modèles à partir de models.dev | -| `--verbose` | Utiliser une sortie de modèle plus détaillée (inclut des métadonnées telles que les coûts) | +| Option | Description | +| --------------------------------------- | ------------------------------------------------------------------------------------------ | +| {"--refresh"} | Actualisez le cache des modèles à partir de models.dev | +| {"--verbose"} | Utiliser une sortie de modèle plus détaillée (inclut des métadonnées telles que les coûts) | Utilisez l'option `--refresh` pour mettre à jour la liste des modèles mis en cache. Ceci est utile lorsque de nouveaux modèles ont été ajoutés à un fournisseur et que vous souhaitez les voir dans OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Options -| Option | Court | Description | -| ------------ | ----- | ---------------------------------------------------------------------------------------------- | -| `--command` | | La commande à exécuter, utilisez le message pour les arguments | -| `--continue` | `-c` | Continuer la dernière session | -| `--session` | `-s` | ID de session pour continuer | -| `--fork` | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | -| `--share` | | Partager la session | -| `--model` | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | -| `--agent` | | Agent à utiliser | -| `--file` | `-f` | Fichier(s) à joindre au message | -| `--format` | | Format : par défaut (formaté) ou json (événements JSON bruts) | -| `--title` | | Titre de la session (utilise un prompt tronqué si aucune valeur n'est fournie) | -| `--attach` | | Connectez-vous à un serveur opencode en cours d'exécution (par exemple, http://localhost:4096) | -| `--port` | | Port du serveur local (port aléatoire par défaut) | +| Option | Court | Description | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------------------------------- | +| {"--command"} | | La commande à exécuter, utilisez le message pour les arguments | +| {"--continue"} | `-c` | Continuer la dernière session | +| {"--session"} | `-s` | ID de session pour continuer | +| {"--fork"} | | Forker la session en continuant (à utiliser avec `--continue` ou `--session`) | +| {"--share"} | | Partager la session | +| {"--model"} | `-m` | Modèle à utiliser sous forme de fournisseur/modèle | +| {"--agent"} | | Agent à utiliser | +| {"--file"} | `-f` | Fichier(s) à joindre au message | +| {"--format"} | | Format : par défaut (formaté) ou json (événements JSON bruts) | +| {"--title"} | | Titre de la session (utilise un prompt tronqué si aucune valeur n'est fournie) | +| {"--attach"} | | Connectez-vous à un serveur opencode en cours d'exécution (par exemple, http://localhost:4096) | +| {"--password"} | `-p` | Mot de passe d'authentification de base (par défaut `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nom d'utilisateur d'authentification de base (par défaut `OPENCODE_SERVER_USERNAME` ou `opencode`) | +| {"--dir"} | | Répertoire d'exécution, ou chemin sur le serveur distant lors de l'attachement | +| {"--variant"} | | Variante du modèle (effort de raisonnement spécifique au fournisseur) | +| {"--thinking"} | | Afficher les blocs de réflexion | +| {"--port"} | | Port du serveur local (port aléatoire par défaut) | --- @@ -364,12 +373,12 @@ Cela démarre un serveur HTTP qui fournit un accès API aux fonctionnalités d'O #### Options -| Option | Description | -| ------------ | ---------------------------------------------------------- | -| `--port` | Port d'écoute | -| `--hostname` | Nom d'hôte d'écoute | -| `--mdns` | Activer la découverte mDNS | -| `--cors` | Origines de navigateur supplémentaires pour autoriser CORS | +| Option | Description | +| ---------------------------------------- | ---------------------------------------------------------- | +| {"--port"} | Port d'écoute | +| {"--hostname"} | Nom d'hôte d'écoute | +| {"--mdns"} | Activer la découverte mDNS | +| {"--cors"} | Origines de navigateur supplémentaires pour autoriser CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Options -| Option | Court | Description | -| ------------- | ----- | -------------------------------------------- | -| `--max-count` | `-n` | Limiter aux N sessions les plus récentes | -| `--format` | | Format de sortie : tableau ou json (tableau) | +| Option | Court | Description | +| ----------------------------------------- | ----- | -------------------------------------------- | +| {"--max-count"} | `-n` | Limiter aux N sessions les plus récentes | +| {"--format"} | | Format de sortie : tableau ou json (tableau) | --- @@ -410,12 +419,12 @@ opencode stats #### Options -| Option | Description | -| ----------- | --------------------------------------------------------------------------------------------------------------------- | -| `--days` | Afficher les statistiques des N derniers jours (depuis le début) | -| `--tools` | Nombre d'outils à afficher (tous) | -| `--models` | Afficher la répartition de l'utilisation du modèle (masqué par défaut). Passez un numéro pour afficher les N premiers | -| `--project` | Filtrer par projet (tous les projets, chaîne vide : projet actuel) | +| Option | Description | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| {"--days"} | Afficher les statistiques des N derniers jours (depuis le début) | +| {"--tools"} | Nombre d'outils à afficher (tous) | +| {"--models"} | Afficher la répartition de l'utilisation du modèle (masqué par défaut). Passez un numéro pour afficher les N premiers | +| {"--project"} | Filtrer par projet (tous les projets, chaîne vide : projet actuel) | --- @@ -460,12 +469,12 @@ Cela démarre un serveur HTTP et ouvre un navigateur Web pour accéder à OpenCo #### Options -| Option | Description | -| ------------ | ---------------------------------------------------------- | -| `--port` | Port d'écoute | -| `--hostname` | Nom d'hôte d'écoute | -| `--mdns` | Activer la découverte mDNS | -| `--cors` | Origines de navigateur supplémentaires pour autoriser CORS | +| Option | Description | +| ---------------------------------------- | ---------------------------------------------------------- | +| {"--port"} | Port d'écoute | +| {"--hostname"} | Nom d'hôte d'écoute | +| {"--mdns"} | Activer la découverte mDNS | +| {"--cors"} | Origines de navigateur supplémentaires pour autoriser CORS | --- @@ -481,11 +490,11 @@ Cette commande démarre un serveur ACP qui communique via stdin/stdout en utilis #### Options -| Option | Description | -| ------------ | --------------------- | -| `--cwd` | Répertoire de travail | -| `--port` | Port d'écoute | -| `--hostname` | Nom d'hôte d'écoute | +| Option | Description | +| ---------------------------------------- | --------------------- | +| {"--cwd"} | Répertoire de travail | +| {"--port"} | Port d'écoute | +| {"--hostname"} | Nom d'hôte d'écoute | --- @@ -499,12 +508,12 @@ opencode uninstall #### Options -| Option | Court | Description | -| --------------- | ----- | --------------------------------------------------- | -| `--keep-config` | `-c` | Conserver les fichiers de configuration | -| `--keep-data` | `-d` | Conserver les données de session et les instantanés | -| `--dry-run` | | Afficher ce qui serait supprimé sans supprimer | -| `--force` | `-f` | Ignorer les invites de confirmation | +| Option | Court | Description | +| ------------------------------------------- | ----- | --------------------------------------------------- | +| {"--keep-config"} | `-c` | Conserver les fichiers de configuration | +| {"--keep-data"} | `-d` | Conserver les données de session et les instantanés | +| {"--dry-run"} | | Afficher ce qui serait supprimé sans supprimer | +| {"--force"} | `-f` | Ignorer les invites de confirmation | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Options -| Option | Court | Description | -| ---------- | ----- | --------------------------------------------------------------- | -| `--method` | `-m` | La méthode d'installation utilisée ; curl, npm, pnpm, bun, brew | +| Option | Court | Description | +| -------------------------------------- | ----- | --------------------------------------------------------------- | +| {"--method"} | `-m` | La méthode d'installation utilisée ; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 La CLI opencode prend les flags globaux suivants. -| Option | Court | Description | -| -------------- | ----- | ---------------------------------------- | -| `--help` | `-h` | Afficher l'aide | -| `--version` | `-v` | Afficher le numéro de version | -| `--print-logs` | | Afficher les logs sur stderr | -| `--log-level` | | Niveau de log (DEBUG, INFO, WARN, ERROR) | +| Option | Court | Description | +| ------------------------------------------ | ----- | ---------------------------------------- | +| {"--help"} | `-h` | Afficher l'aide | +| {"--version"} | `-v` | Afficher le numéro de version | +| {"--print-logs"} | | Afficher les logs sur stderr | +| {"--log-level"} | | Niveau de log (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/it/cli.mdx b/packages/web/src/content/docs/it/cli.mdx index 973eb0d988..6fd3d56769 100644 --- a/packages/web/src/content/docs/it/cli.mdx +++ b/packages/web/src/content/docs/it/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flag -| Flag | Breve | Descrizione | -| ------------ | ----- | ------------------------------------------------------------------------ | -| `--continue` | `-c` | Continua l'ultima sessione | -| `--session` | `-s` | ID sessione da continuare | -| `--fork` | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | -| `--prompt` | | Prompt da usare | -| `--model` | `-m` | Modello nel formato provider/model | -| `--agent` | | Agente da usare | -| `--port` | | Porta su cui mettersi in ascolto | -| `--hostname` | | Hostname su cui mettersi in ascolto | +| Flag | Breve | Descrizione | +| ---------------------------------------- | ----- | ------------------------------------------------------------------------ | +| {"--continue"} | `-c` | Continua l'ultima sessione | +| {"--session"} | `-s` | ID sessione da continuare | +| {"--fork"} | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | +| {"--prompt"} | | Prompt da usare | +| {"--model"} | `-m` | Modello nel formato provider/model | +| {"--agent"} | | Agente da usare | +| {"--port"} | | Porta su cui mettersi in ascolto | +| {"--hostname"} | | Hostname su cui mettersi in ascolto | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flag -| Flag | Breve | Descrizione | -| ----------- | ----- | --------------------------------------- | -| `--dir` | | Working directory in cui avviare la TUI | -| `--session` | `-s` | ID sessione da continuare | +| Flag | Breve | Descrizione | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------- | +| {"--dir"} | | Working directory in cui avviare la TUI | +| {"--continue"} | `-c` | Continua l'ultima sessione | +| {"--session"} | `-s` | ID sessione da continuare | +| {"--fork"} | | Crea un fork della sessione durante la continuazione (usa con `--continue` o `--session`) | +| {"--password"} | `-p` | Password per l'autenticazione di base (predefinita: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nome utente per l'autenticazione di base (predefinito: `OPENCODE_SERVER_USERNAME` o `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flag -| Flag | Descrizione | -| --------- | -------------------------------------------- | -| `--event` | Evento GitHub mock per cui eseguire l'agente | -| `--token` | GitHub personal access token | +| Flag | Descrizione | +| ------------------------------------- | -------------------------------------------- | +| {"--event"} | Evento GitHub mock per cui eseguire l'agente | +| {"--token"} | GitHub personal access token | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flag -| Flag | Descrizione | -| ----------- | -------------------------------------------------- | -| `--refresh` | Aggiorna la cache modelli da models.dev | -| `--verbose` | Output più verboso (include metadati come i costi) | +| Flag | Descrizione | +| --------------------------------------- | -------------------------------------------------- | +| {"--refresh"} | Aggiorna la cache modelli da models.dev | +| {"--verbose"} | Output più verboso (include metadati come i costi) | Usa `--refresh` per aggiornare l'elenco modelli in cache. È utile quando nuovi modelli vengono aggiunti a un provider e vuoi vederli in OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flag -| Flag | Breve | Descrizione | -| ------------ | ----- | ------------------------------------------------------------------------ | -| `--command` | | Il comando da eseguire; usa message per gli argomenti | -| `--continue` | `-c` | Continua l'ultima sessione | -| `--session` | `-s` | ID sessione da continuare | -| `--fork` | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | -| `--share` | | Condividi la sessione | -| `--model` | `-m` | Modello nel formato provider/model | -| `--agent` | | Agente da usare | -| `--file` | `-f` | File da allegare al messaggio | -| `--format` | | Formato: default (formattato) o json (eventi JSON grezzi) | -| `--title` | | Titolo sessione (usa prompt troncato se non viene fornito un valore) | -| `--attach` | | Attach a un server opencode in esecuzione (es. http://localhost:4096) | -| `--port` | | Porta per il server locale (di default una porta casuale) | +| Flag | Breve | Descrizione | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------- | +| {"--command"} | | Il comando da eseguire; usa message per gli argomenti | +| {"--continue"} | `-c` | Continua l'ultima sessione | +| {"--session"} | `-s` | ID sessione da continuare | +| {"--fork"} | | Duplica la sessione quando continui (usa con `--continue` o `--session`) | +| {"--share"} | | Condividi la sessione | +| {"--model"} | `-m` | Modello nel formato provider/model | +| {"--agent"} | | Agente da usare | +| {"--file"} | `-f` | File da allegare al messaggio | +| {"--format"} | | Formato: default (formattato) o json (eventi JSON grezzi) | +| {"--title"} | | Titolo sessione (usa prompt troncato se non viene fornito un valore) | +| {"--attach"} | | Attach a un server opencode in esecuzione (es. http://localhost:4096) | +| {"--password"} | `-p` | Password per l'autenticazione di base (predefinita: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nome utente per l'autenticazione di base (predefinito: `OPENCODE_SERVER_USERNAME` o `opencode`) | +| {"--dir"} | | Directory di esecuzione, o percorso sul server remoto durante il collegamento | +| {"--variant"} | | Variante del modello (sforzo di ragionamento specifico del provider) | +| {"--thinking"} | | Mostra blocchi di pensiero | +| {"--port"} | | Porta per il server locale (di default una porta casuale) | --- @@ -364,12 +373,12 @@ Avvia un server HTTP che espone accesso API alle funzionalità di opencode senza #### Flag -| Flag | Descrizione | -| ------------ | ---------------------------------------------- | -| `--port` | Porta su cui mettersi in ascolto | -| `--hostname` | Hostname su cui mettersi in ascolto | -| `--mdns` | Abilita discovery mDNS | -| `--cors` | Origin browser addizionali per consentire CORS | +| Flag | Descrizione | +| ---------------------------------------- | ---------------------------------------------- | +| {"--port"} | Porta su cui mettersi in ascolto | +| {"--hostname"} | Hostname su cui mettersi in ascolto | +| {"--mdns"} | Abilita discovery mDNS | +| {"--cors"} | Origin browser addizionali per consentire CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flag -| Flag | Breve | Descrizione | -| ------------- | ----- | ------------------------------------ | -| `--max-count` | `-n` | Limita alle N sessioni più recenti | -| `--format` | | Formato output: table o json (table) | +| Flag | Breve | Descrizione | +| ----------------------------------------- | ----- | ------------------------------------ | +| {"--max-count"} | `-n` | Limita alle N sessioni più recenti | +| {"--format"} | | Formato output: table o json (table) | --- @@ -410,12 +419,12 @@ opencode stats #### Flag -| Flag | Descrizione | -| ----------- | ------------------------------------------------------------------------------------- | -| `--days` | Mostra statistiche per gli ultimi N giorni (all time) | -| `--tools` | Numero di strumenti da mostrare (all) | -| `--models` | Mostra breakdown di utilizzo modelli (nascosto di default). Passa un numero per top N | -| `--project` | Filtra per progetto (tutti i progetti; stringa vuota: progetto corrente) | +| Flag | Descrizione | +| --------------------------------------- | ------------------------------------------------------------------------------------- | +| {"--days"} | Mostra statistiche per gli ultimi N giorni (all time) | +| {"--tools"} | Numero di strumenti da mostrare (all) | +| {"--models"} | Mostra breakdown di utilizzo modelli (nascosto di default). Passa un numero per top N | +| {"--project"} | Filtra per progetto (tutti i progetti; stringa vuota: progetto corrente) | --- @@ -460,12 +469,12 @@ Avvia un server HTTP e apre un browser per accedere a OpenCode tramite interfacc #### Flag -| Flag | Descrizione | -| ------------ | ---------------------------------------------- | -| `--port` | Porta su cui mettersi in ascolto | -| `--hostname` | Hostname su cui mettersi in ascolto | -| `--mdns` | Abilita discovery mDNS | -| `--cors` | Origin browser addizionali per consentire CORS | +| Flag | Descrizione | +| ---------------------------------------- | ---------------------------------------------- | +| {"--port"} | Porta su cui mettersi in ascolto | +| {"--hostname"} | Hostname su cui mettersi in ascolto | +| {"--mdns"} | Abilita discovery mDNS | +| {"--cors"} | Origin browser addizionali per consentire CORS | --- @@ -481,11 +490,11 @@ Questo comando avvia un server ACP che comunica via stdin/stdout usando nd-JSON. #### Flag -| Flag | Descrizione | -| ------------ | ----------------------------------- | -| `--cwd` | Directory di lavoro | -| `--port` | Porta su cui mettersi in ascolto | -| `--hostname` | Hostname su cui mettersi in ascolto | +| Flag | Descrizione | +| ---------------------------------------- | ----------------------------------- | +| {"--cwd"} | Directory di lavoro | +| {"--port"} | Porta su cui mettersi in ascolto | +| {"--hostname"} | Hostname su cui mettersi in ascolto | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flag -| Flag | Breve | Descrizione | -| --------------- | ----- | -------------------------------------------- | -| `--keep-config` | `-c` | Mantieni i file di configurazione | -| `--keep-data` | `-d` | Mantieni dati di sessione e snapshot | -| `--dry-run` | | Mostra cosa verrebbe rimosso senza rimuovere | -| `--force` | `-f` | Salta le richieste di conferma | +| Flag | Breve | Descrizione | +| ------------------------------------------- | ----- | -------------------------------------------- | +| {"--keep-config"} | `-c` | Mantieni i file di configurazione | +| {"--keep-data"} | `-d` | Mantieni dati di sessione e snapshot | +| {"--dry-run"} | | Mostra cosa verrebbe rimosso senza rimuovere | +| {"--force"} | `-f` | Salta le richieste di conferma | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Flag -| Flag | Breve | Descrizione | -| ---------- | ----- | --------------------------------------------------------- | -| `--method` | `-m` | Metodo di installazione usato: curl, npm, pnpm, bun, brew | +| Flag | Breve | Descrizione | +| -------------------------------------- | ----- | --------------------------------------------------------- | +| {"--method"} | `-m` | Metodo di installazione usato: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 La CLI di opencode accetta i seguenti flag globali. -| Flag | Breve | Descrizione | -| -------------- | ----- | -------------------------------------- | -| `--help` | `-h` | Mostra l'help | -| `--version` | `-v` | Stampa il numero di versione | -| `--print-logs` | | Stampa i log su stderr | -| `--log-level` | | Livello log (DEBUG, INFO, WARN, ERROR) | +| Flag | Breve | Descrizione | +| ------------------------------------------ | ----- | -------------------------------------- | +| {"--help"} | `-h` | Mostra l'help | +| {"--version"} | `-v` | Stampa il numero di versione | +| {"--print-logs"} | | Stampa i log su stderr | +| {"--log-level"} | | Livello log (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/ja/cli.mdx b/packages/web/src/content/docs/ja/cli.mdx index 82a8852ea5..120803627a 100644 --- a/packages/web/src/content/docs/ja/cli.mdx +++ b/packages/web/src/content/docs/ja/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### フラグ -| フラグ | ショート | 説明 | -| ------------ | ----------- | ---------------------------------------------------------- | -| `--continue` | `-c` | 最後のセッションを続行 | -| `--session` | | 続行時にセッションをフォーク (`-s` または `--fork` と併用) | -| `--continue` | `--session` | 続行するセッション ID | -| `--prompt` | | 使用のプロンプト | -| `--model` | `-m` | プロバイダー/モデルの形式で使用するモデル | -| `--agent` | | 使用するエージェント | -| `--port` | | リッスンするポート | -| `--hostname` | | リッスンするホスト名 | +| フラグ | ショート | 説明 | +| ---------------------------------------- | --------------------------------------- | ---------------------------------------------------------- | +| {"--continue"} | `-c` | 最後のセッションを続行 | +| {"--session"} | | 続行時にセッションをフォーク (`-s` または `--fork` と併用) | +| {"--continue"} | {"--session"} | 続行するセッション ID | +| {"--prompt"} | | 使用のプロンプト | +| {"--model"} | `-m` | プロバイダー/モデルの形式で使用するモデル | +| {"--agent"} | | 使用するエージェント | +| {"--port"} | | リッスンするポート | +| {"--hostname"} | | リッスンするホスト名 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### フラグ -| フラグ | ショート | 説明 | -| ----------- | -------- | ------------------------------ | -| `--dir` | | TUI を開始する作業ディレクトリ | -| `--session` | `-s` | 続行するセッション ID | +| フラグ | ショート | 説明 | +| ---------------------------------------- | -------- | --------------------------------------------------------------------------------- | +| {"--dir"} | | TUI を開始する作業ディレクトリ | +| {"--continue"} | `-c` | 最後のセッションを続行 | +| {"--session"} | `-s` | 続行するセッション ID | +| {"--fork"} | | 続行時にセッションをフォーク(`--continue` または `--session` と使用) | +| {"--password"} | `-p` | Basic 認証パスワード(デフォルトは `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic 認証ユーザー名(デフォルトは `OPENCODE_SERVER_USERNAME` または `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### フラグ -| フラグ | 説明 | -| --------- | --------------------------------------------------- | -| `--event` | エージェントを実行するための GitHub モック イベント | -| `--token` | GitHub 個人アクセストークン | +| フラグ | 説明 | +| ------------------------------------- | --------------------------------------------------- | +| {"--event"} | エージェントを実行するための GitHub モック イベント | +| {"--token"} | GitHub 個人アクセストークン | --- @@ -296,10 +300,10 @@ opencode models anthropic #### フラグ -| フラグ | 説明 | -| ----------- | --------------------------------------------------------------- | -| `--refresh` | models.dev からモデルキャッシュを更新します。 | -| `--verbose` | より詳細なモデル出力を使用します (コストなどのメタデータを含む) | +| フラグ | 説明 | +| --------------------------------------- | --------------------------------------------------------------- | +| {"--refresh"} | models.dev からモデルキャッシュを更新します。 | +| {"--verbose"} | より詳細なモデル出力を使用します (コストなどのメタデータを含む) | `--refresh` フラグを使用して、キャッシュされたモデルリストを更新します。これは、新しいモデルがプロバイダーに追加され、それを OpenCode で確認したい場合に便利です。 @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### フラグ -| フラグ | ショート | 説明 | -| ------------ | -------- | ----------------------------------------------------------------------------------------- | -| `--command` | | 実行するコマンド。引数には message を使用します。 | -| `--continue` | `-c` | 最後のセッションを続行 | -| `--session` | `-s` | 続行するセッション ID | -| `--fork` | | 続行時にセッションをフォーク (`--continue` または `--session` と併用) | -| `--share` | | セッションを共有する | -| `--model` | `-m` | プロバイダー/モデルの形式で使用するモデル | -| `--agent` | | 使用するエージェント | -| `--file` | `-f` | メッセージに添付するファイル | -| `--format` | | 形式: デフォルト (フォーマット済み) または json (生の JSON イベント) | -| `--title` | | セッションのタイトル (値が指定されていない場合は、切り詰められたプロンプトが使用されます) | -| `--attach` | | 実行中の opencode サーバー (http://localhost:4096 など) に接続します。 | -| `--port` | | ローカルサーバーのポート (デフォルトはランダムポート) | +| フラグ | ショート | 説明 | +| ---------------------------------------- | -------- | ----------------------------------------------------------------------------------------- | +| {"--command"} | | 実行するコマンド。引数には message を使用します。 | +| {"--continue"} | `-c` | 最後のセッションを続行 | +| {"--session"} | `-s` | 続行するセッション ID | +| {"--fork"} | | 続行時にセッションをフォーク (`--continue` または `--session` と併用) | +| {"--share"} | | セッションを共有する | +| {"--model"} | `-m` | プロバイダー/モデルの形式で使用するモデル | +| {"--agent"} | | 使用するエージェント | +| {"--file"} | `-f` | メッセージに添付するファイル | +| {"--format"} | | 形式: デフォルト (フォーマット済み) または json (生の JSON イベント) | +| {"--title"} | | セッションのタイトル (値が指定されていない場合は、切り詰められたプロンプトが使用されます) | +| {"--attach"} | | 実行中の opencode サーバー (http://localhost:4096 など) に接続します。 | +| {"--password"} | `-p` | Basic 認証パスワード(デフォルトは `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic 認証ユーザー名(デフォルトは `OPENCODE_SERVER_USERNAME` または `opencode`) | +| {"--dir"} | | 実行ディレクトリ、またはアタッチ時のリモートサーバー上のパス | +| {"--variant"} | | モデルバリアント(プロバイダー固有の推論レベル) | +| {"--thinking"} | | 思考ブロックを表示 | +| {"--port"} | | ローカルサーバーのポート (デフォルトはランダムポート) | --- @@ -364,12 +373,12 @@ opencode serve #### フラグ -| フラグ | 説明 | -| ------------ | --------------------------------------- | -| `--port` | リッスンするポート | -| `--hostname` | リッスンするホスト名 | -| `--mdns` | mDNS 検出を有効にする | -| `--cors` | CORS を許可する追加のブラウザーオリジン | +| フラグ | 説明 | +| ---------------------------------------- | --------------------------------------- | +| {"--port"} | リッスンするポート | +| {"--hostname"} | リッスンするホスト名 | +| {"--mdns"} | mDNS 検出を有効にする | +| {"--cors"} | CORS を許可する追加のブラウザーオリジン | --- @@ -393,10 +402,10 @@ opencode session list ##### フラグ -| フラグ | ショート | 説明 | -| ------------- | -------- | ---------------------------------------- | -| `--max-count` | `-n` | 最新のセッションを N 個に制限 | -| `--format` | | 出力形式: テーブルまたは json (テーブル) | +| フラグ | ショート | 説明 | +| ----------------------------------------- | -------- | ---------------------------------------- | +| {"--max-count"} | `-n` | 最新のセッションを N 個に制限 | +| {"--format"} | | 出力形式: テーブルまたは json (テーブル) | --- @@ -410,12 +419,12 @@ opencode stats #### フラグ -| フラグ | 説明 | -| ----------- | ------------------------------------------------------------------------------------------ | -| `--days` | 過去 N 日間の統計を表示 (全期間) | -| `--tools` | 表示するツールの数 (すべて) | -| `--models` | モデルの使用状況の内訳を表示 (デフォルトでは非表示)。上位 N 件を表示するには数値を渡します | -| `--project` | プロジェクトでフィルタリング (全プロジェクト、空文字列: 現在のプロジェクト) | +| フラグ | 説明 | +| --------------------------------------- | ------------------------------------------------------------------------------------------ | +| {"--days"} | 過去 N 日間の統計を表示 (全期間) | +| {"--tools"} | 表示するツールの数 (すべて) | +| {"--models"} | モデルの使用状況の内訳を表示 (デフォルトでは非表示)。上位 N 件を表示するには数値を渡します | +| {"--project"} | プロジェクトでフィルタリング (全プロジェクト、空文字列: 現在のプロジェクト) | --- @@ -460,12 +469,12 @@ opencode web #### フラグ -| フラグ | 説明 | -| ------------ | --------------------------------------- | -| `--port` | リッスンするポート | -| `--hostname` | リッスンするホスト名 | -| `--mdns` | mDNS 検出を有効にする | -| `--cors` | CORS を許可する追加のブラウザーオリジン | +| フラグ | 説明 | +| ---------------------------------------- | --------------------------------------- | +| {"--port"} | リッスンするポート | +| {"--hostname"} | リッスンするホスト名 | +| {"--mdns"} | mDNS 検出を有効にする | +| {"--cors"} | CORS を許可する追加のブラウザーオリジン | --- @@ -481,11 +490,11 @@ opencode acp #### フラグ -| フラグ | 説明 | -| ------------ | -------------------- | -| `--cwd` | 作業ディレクトリ | -| `--port` | リッスンするポート | -| `--hostname` | リッスンするホスト名 | +| フラグ | 説明 | +| ---------------------------------------- | -------------------- | +| {"--cwd"} | 作業ディレクトリ | +| {"--port"} | リッスンするポート | +| {"--hostname"} | リッスンするホスト名 | --- @@ -499,12 +508,12 @@ opencode uninstall #### フラグ -| フラグ | ショート | 説明 | -| --------------- | -------- | -------------------------------------------- | -| `--keep-config` | `-c` | 構成ファイルを保持する | -| `--keep-data` | `-d` | セッションデータとスナップショットを保持する | -| `--dry-run` | | 削除せずに削除される内容を表示する | -| `--force` | `-f` | 確認プロンプトをスキップする | +| フラグ | ショート | 説明 | +| ------------------------------------------- | -------- | -------------------------------------------- | +| {"--keep-config"} | `-c` | 構成ファイルを保持する | +| {"--keep-data"} | `-d` | セッションデータとスナップショットを保持する | +| {"--dry-run"} | | 削除せずに削除される内容を表示する | +| {"--force"} | `-f` | 確認プロンプトをスキップする | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### フラグ -| フラグ | ショート | 説明 | -| ---------- | -------- | ------------------------------------------------------ | -| `--method` | `-m` | 使用されたインストール方法。curl, npm, pnpm, bun, brew | +| フラグ | ショート | 説明 | +| -------------------------------------- | -------- | ------------------------------------------------------ | +| {"--method"} | `-m` | 使用されたインストール方法。curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI は次のグローバルフラグを受け取ります。 -| フラグ | ショート | 説明 | -| -------------- | -------- | ------------------------------------- | -| `--help` | `-h` | ヘルプを表示 | -| `--version` | `-v` | バージョン番号を出力 | -| `--print-logs` | | ログを標準エラー出力に出力 | -| `--log-level` | | ログレベル (DEBUG、INFO、WARN、ERROR) | +| フラグ | ショート | 説明 | +| ------------------------------------------ | -------- | ------------------------------------- | +| {"--help"} | `-h` | ヘルプを表示 | +| {"--version"} | `-v` | バージョン番号を出力 | +| {"--print-logs"} | | ログを標準エラー出力に出力 | +| {"--log-level"} | | ログレベル (DEBUG、INFO、WARN、ERROR) | --- diff --git a/packages/web/src/content/docs/ko/cli.mdx b/packages/web/src/content/docs/ko/cli.mdx index b0ce10567e..7b829c7f37 100644 --- a/packages/web/src/content/docs/ko/cli.mdx +++ b/packages/web/src/content/docs/ko/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### 플래그 -| 플래그 | 축약 | 설명 | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--continue` | `-c` | 마지막 세션 이어서 실행 | -| `--session` | `-s` | 이어서 실행할 세션 ID | -| `--fork` | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | -| `--prompt` | | 사용할 프롬프트 | -| `--model` | `-m` | 사용할 모델 (`provider/model` 형식) | -| `--agent` | | 사용할 에이전트 | -| `--port` | | 수신 포트 | -| `--hostname` | | 수신 호스트명 | +| 플래그 | 축약 | 설명 | +| ---------------------------------------- | ---- | ---------------------------------------------------------------------- | +| {"--continue"} | `-c` | 마지막 세션 이어서 실행 | +| {"--session"} | `-s` | 이어서 실행할 세션 ID | +| {"--fork"} | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | +| {"--prompt"} | | 사용할 프롬프트 | +| {"--model"} | `-m` | 사용할 모델 (`provider/model` 형식) | +| {"--agent"} | | 사용할 에이전트 | +| {"--port"} | | 수신 포트 | +| {"--hostname"} | | 수신 호스트명 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### 플래그 -| 플래그 | 축약 | 설명 | -| ----------- | ---- | -------------------------- | -| `--dir` | | TUI를 시작할 작업 디렉터리 | -| `--session` | `-s` | 이어서 실행할 세션 ID | +| 플래그 | 축약 | 설명 | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------- | +| {"--dir"} | | TUI를 시작할 작업 디렉터리 | +| {"--continue"} | `-c` | 마지막 세션 이어서 실행 | +| {"--session"} | `-s` | 이어서 실행할 세션 ID | +| {"--fork"} | | 이어서 실행할 때 세션 포크 (`--continue` 또는 `--session`과 함께 사용) | +| {"--password"} | `-p` | 기본 인증 비밀번호 (기본값: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 기본 인증 사용자 이름 (기본값: `OPENCODE_SERVER_USERNAME` 또는 `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### 플래그 -| 플래그 | 설명 | -| --------- | ------------------------- | -| `--event` | 실행할 GitHub 모의 이벤트 | -| `--token` | GitHub 개인 액세스 토큰 | +| 플래그 | 설명 | +| ------------------------------------- | ------------------------- | +| {"--event"} | 실행할 GitHub 모의 이벤트 | +| {"--token"} | GitHub 개인 액세스 토큰 | --- @@ -296,10 +300,10 @@ opencode models anthropic #### 플래그 -| 플래그 | 설명 | -| ----------- | ------------------------------------------------- | -| `--refresh` | models.dev에서 모델 캐시 새로고침 | -| `--verbose` | 더 자세한 모델 출력 사용(비용 등 메타데이터 포함) | +| 플래그 | 설명 | +| --------------------------------------- | ------------------------------------------------- | +| {"--refresh"} | models.dev에서 모델 캐시 새로고침 | +| {"--verbose"} | 더 자세한 모델 출력 사용(비용 등 메타데이터 포함) | `--refresh` 플래그를 사용하면 캐시된 모델 목록을 갱신할 수 있습니다. provider에 새 모델이 추가된 뒤 OpenCode에서 바로 확인하고 싶을 때 유용합니다. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### 플래그 -| 플래그 | 축약 | 설명 | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--command` | | 실행할 명령(인수는 message로 전달) | -| `--continue` | `-c` | 마지막 세션 이어서 실행 | -| `--session` | `-s` | 이어서 실행할 세션 ID | -| `--fork` | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | -| `--share` | | 세션 공유 | -| `--model` | `-m` | 사용할 모델 (`provider/model` 형식) | -| `--agent` | | 사용할 에이전트 | -| `--file` | `-f` | 메시지에 첨부할 파일 | -| `--format` | | 출력 형식: default(포맷됨) 또는 json(원시 JSON 이벤트) | -| `--title` | | 세션 제목(값이 없으면 프롬프트를 잘라 자동 생성) | -| `--attach` | | 실행 중인 opencode 서버에 연결(예: http://localhost:4096) | -| `--port` | | 로컬 서버 포트(기본값: 랜덤 포트) | +| 플래그 | 축약 | 설명 | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------- | +| {"--command"} | | 실행할 명령(인수는 message로 전달) | +| {"--continue"} | `-c` | 마지막 세션 이어서 실행 | +| {"--session"} | `-s` | 이어서 실행할 세션 ID | +| {"--fork"} | | 세션을 이어갈 때 포크 생성 (`--continue` 또는 `--session`과 함께 사용) | +| {"--share"} | | 세션 공유 | +| {"--model"} | `-m` | 사용할 모델 (`provider/model` 형식) | +| {"--agent"} | | 사용할 에이전트 | +| {"--file"} | `-f` | 메시지에 첨부할 파일 | +| {"--format"} | | 출력 형식: default(포맷됨) 또는 json(원시 JSON 이벤트) | +| {"--title"} | | 세션 제목(값이 없으면 프롬프트를 잘라 자동 생성) | +| {"--attach"} | | 실행 중인 opencode 서버에 연결(예: http://localhost:4096) | +| {"--password"} | `-p` | 기본 인증 비밀번호 (기본값: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 기본 인증 사용자 이름 (기본값: `OPENCODE_SERVER_USERNAME` 또는 `opencode`) | +| {"--dir"} | | 실행할 디렉터리, 또는 연결 시 원격 서버 경로 | +| {"--variant"} | | 모델 변형 (제공자별 추론 수준) | +| {"--thinking"} | | 사고 블록 표시 | +| {"--port"} | | 로컬 서버 포트(기본값: 랜덤 포트) | --- @@ -364,12 +373,12 @@ opencode serve #### 플래그 -| 플래그 | 설명 | -| ------------ | --------------------------------- | -| `--port` | 수신 포트 | -| `--hostname` | 수신 호스트명 | -| `--mdns` | mDNS 검색 활성화 | -| `--cors` | 허용할 추가 브라우저 origin(CORS) | +| 플래그 | 설명 | +| ---------------------------------------- | --------------------------------- | +| {"--port"} | 수신 포트 | +| {"--hostname"} | 수신 호스트명 | +| {"--mdns"} | mDNS 검색 활성화 | +| {"--cors"} | 허용할 추가 브라우저 origin(CORS) | --- @@ -393,10 +402,10 @@ opencode session list ##### 플래그 -| 플래그 | 축약 | 설명 | -| ------------- | ---- | -------------------------------------- | -| `--max-count` | `-n` | 최근 N개 세션만 표시 | -| `--format` | | 출력 형식: table 또는 json(기본 table) | +| 플래그 | 축약 | 설명 | +| ----------------------------------------- | ---- | -------------------------------------- | +| {"--max-count"} | `-n` | 최근 N개 세션만 표시 | +| {"--format"} | | 출력 형식: table 또는 json(기본 table) | --- @@ -410,12 +419,12 @@ opencode stats #### 플래그 -| 플래그 | 설명 | -| ----------- | ------------------------------------------------------------ | -| `--days` | 최근 N일 통계 표시(기본값: 전체 기간) | -| `--tools` | 표시할 도구 개수(기본값: 전체) | -| `--models` | 모델 사용량 상세 표시(기본 숨김). 숫자를 주면 상위 N개 표시 | -| `--project` | 프로젝트 필터(기본: 전체 프로젝트, 빈 문자열: 현재 프로젝트) | +| 플래그 | 설명 | +| --------------------------------------- | ------------------------------------------------------------ | +| {"--days"} | 최근 N일 통계 표시(기본값: 전체 기간) | +| {"--tools"} | 표시할 도구 개수(기본값: 전체) | +| {"--models"} | 모델 사용량 상세 표시(기본 숨김). 숫자를 주면 상위 N개 표시 | +| {"--project"} | 프로젝트 필터(기본: 전체 프로젝트, 빈 문자열: 현재 프로젝트) | --- @@ -460,12 +469,12 @@ opencode web #### 플래그 -| 플래그 | 설명 | -| ------------ | --------------------------------- | -| `--port` | 수신 포트 | -| `--hostname` | 수신 호스트명 | -| `--mdns` | mDNS 검색 활성화 | -| `--cors` | 허용할 추가 브라우저 origin(CORS) | +| 플래그 | 설명 | +| ---------------------------------------- | --------------------------------- | +| {"--port"} | 수신 포트 | +| {"--hostname"} | 수신 호스트명 | +| {"--mdns"} | mDNS 검색 활성화 | +| {"--cors"} | 허용할 추가 브라우저 origin(CORS) | --- @@ -481,11 +490,11 @@ opencode acp #### 플래그 -| 플래그 | 설명 | -| ------------ | ------------- | -| `--cwd` | 작업 디렉터리 | -| `--port` | 수신 포트 | -| `--hostname` | 수신 호스트명 | +| 플래그 | 설명 | +| ---------------------------------------- | ------------- | +| {"--cwd"} | 작업 디렉터리 | +| {"--port"} | 수신 포트 | +| {"--hostname"} | 수신 호스트명 | --- @@ -499,12 +508,12 @@ opencode uninstall #### 플래그 -| 플래그 | 축약 | 설명 | -| --------------- | ---- | ------------------------------- | -| `--keep-config` | `-c` | 설정 파일 유지 | -| `--keep-data` | `-d` | 세션 데이터와 스냅샷 유지 | -| `--dry-run` | | 실제 삭제 없이 삭제 대상만 표시 | -| `--force` | `-f` | 확인 프롬프트 건너뛰기 | +| 플래그 | 축약 | 설명 | +| ------------------------------------------- | ---- | ------------------------------- | +| {"--keep-config"} | `-c` | 설정 파일 유지 | +| {"--keep-data"} | `-d` | 세션 데이터와 스냅샷 유지 | +| {"--dry-run"} | | 실제 삭제 없이 삭제 대상만 표시 | +| {"--force"} | `-f` | 확인 프롬프트 건너뛰기 | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### 플래그 -| 플래그 | 축약 | 설명 | -| ---------- | ---- | ------------------------------------------ | -| `--method` | `-m` | 설치 방식 지정: curl, npm, pnpm, bun, brew | +| 플래그 | 축약 | 설명 | +| -------------------------------------- | ---- | ------------------------------------------ | +| {"--method"} | `-m` | 설치 방식 지정: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI는 아래 전역 플래그를 지원합니다. -| 플래그 | 축약 | 설명 | -| -------------- | ---- | ----------------------------------- | -| `--help` | `-h` | 도움말 표시 | -| `--version` | `-v` | 버전 출력 | -| `--print-logs` | | 로그를 stderr로 출력 | -| `--log-level` | | 로그 레벨(DEBUG, INFO, WARN, ERROR) | +| 플래그 | 축약 | 설명 | +| ------------------------------------------ | ---- | ----------------------------------- | +| {"--help"} | `-h` | 도움말 표시 | +| {"--version"} | `-v` | 버전 출력 | +| {"--print-logs"} | | 로그를 stderr로 출력 | +| {"--log-level"} | | 로그 레벨(DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/nb/cli.mdx b/packages/web/src/content/docs/nb/cli.mdx index 8312a1a7c5..824a3dcadd 100644 --- a/packages/web/src/content/docs/nb/cli.mdx +++ b/packages/web/src/content/docs/nb/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flagg -| Flagg | Kort | Beskrivelse | -| ------------ | ---- | ------------------------------------------------------------------------ | -| `--continue` | `-c` | Fortsett siste økt | -| `--session` | `-s` | Økt ID for å fortsette | -| `--fork` | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | -| `--prompt` | | Ledetekst som skal brukes | -| `--model` | `-m` | Modell å bruke i form av leverandør/modell | -| `--agent` | | Agent som skal brukes | -| `--port` | | Port å lytte på | -| `--hostname` | | Vertsnavn å lytte på | +| Flagg | Kort | Beskrivelse | +| ---------------------------------------- | ---- | ------------------------------------------------------------------------ | +| {"--continue"} | `-c` | Fortsett siste økt | +| {"--session"} | `-s` | Økt ID for å fortsette | +| {"--fork"} | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | +| {"--prompt"} | | Ledetekst som skal brukes | +| {"--model"} | `-m` | Modell å bruke i form av leverandør/modell | +| {"--agent"} | | Agent som skal brukes | +| {"--port"} | | Port å lytte på | +| {"--hostname"} | | Vertsnavn å lytte på | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flagg -| Flagg | Kort | Beskrivelse | -| ----------- | ---- | --------------------------------- | -| `--dir` | | Arbeidskatalog for å starte TUI i | -| `--session` | `-s` | Økt ID for å fortsette | +| Flagg | Kort | Beskrivelse | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------------------------------- | +| {"--dir"} | | Arbeidskatalog for å starte TUI i | +| {"--continue"} | `-c` | Fortsett siste økt | +| {"--session"} | `-s` | Økt ID for å fortsette | +| {"--fork"} | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | +| {"--password"} | `-p` | Passord for grunnleggende autentisering (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brukernavn for grunnleggende autentisering (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flagg -| Flagg | Beskrivelse | -| --------- | -------------------------------------------- | -| `--event` | GitHub mock-hendelse agenten skal kjøres for | -| `--token` | GitHub personlig tilgangsnøkkel | +| Flagg | Beskrivelse | +| ------------------------------------- | -------------------------------------------- | +| {"--event"} | GitHub mock-hendelse agenten skal kjøres for | +| {"--token"} | GitHub personlig tilgangsnøkkel | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flagg -| Flagg | Beskrivelse | -| ----------- | ------------------------------------------------------------------- | -| `--refresh` | Oppdater modellbufferen fra models.dev | -| `--verbose` | Bruk mer detaljert modellutdata (inkluderer metadata som kostnader) | +| Flagg | Beskrivelse | +| --------------------------------------- | ------------------------------------------------------------------- | +| {"--refresh"} | Oppdater modellbufferen fra models.dev | +| {"--verbose"} | Bruk mer detaljert modellutdata (inkluderer metadata som kostnader) | Bruk `--refresh`-flagget for å oppdatere den bufrede modelllisten. Dette er nyttig når nye modeller er lagt til en leverandør og du vil se dem i OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flagg -| Flagg | Kort | Beskrivelse | -| ------------ | ---- | ------------------------------------------------------------------------ | -| `--command` | | Kommandoen for å kjøre, bruk melding for args | -| `--continue` | `-c` | Fortsett siste økt | -| `--session` | `-s` | Økt ID for å fortsette | -| `--fork` | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | -| `--share` | | Del økten | -| `--model` | `-m` | Modell å bruke i form av leverandør/modell | -| `--agent` | | Agent å bruke | -| `--file` | `-f` | Fil(er) som skal legges ved meldingen | -| `--format` | | Format: standard (formatert) eller json (rå JSON hendelser) | -| `--title` | | Tittel for økten (bruker avkortet ledetekst hvis ingen verdi er oppgitt) | -| `--attach` | | Koble til en kjørende OpenCode-server (f.eks. http://localhost:4096) | -| `--port` | | Port for den lokale serveren (standard til tilfeldig port) | +| Flagg | Kort | Beskrivelse | +| ---------------------------------------- | ---- | -------------------------------------------------------------------------------------------------- | +| {"--command"} | | Kommandoen for å kjøre, bruk melding for args | +| {"--continue"} | `-c` | Fortsett siste økt | +| {"--session"} | `-s` | Økt ID for å fortsette | +| {"--fork"} | | Forgren økten ved fortsettelse (bruk med `--continue` eller `--session`) | +| {"--share"} | | Del økten | +| {"--model"} | `-m` | Modell å bruke i form av leverandør/modell | +| {"--agent"} | | Agent å bruke | +| {"--file"} | `-f` | Fil(er) som skal legges ved meldingen | +| {"--format"} | | Format: standard (formatert) eller json (rå JSON hendelser) | +| {"--title"} | | Tittel for økten (bruker avkortet ledetekst hvis ingen verdi er oppgitt) | +| {"--attach"} | | Koble til en kjørende OpenCode-server (f.eks. http://localhost:4096) | +| {"--password"} | `-p` | Passord for grunnleggende autentisering (standard: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Brukernavn for grunnleggende autentisering (standard: `OPENCODE_SERVER_USERNAME` eller `opencode`) | +| {"--dir"} | | Katalog å kjøre i, eller sti på fjernserveren ved tilkobling | +| {"--variant"} | | Modellvariant (leverandørspesifikk resonneringsinnsats) | +| {"--thinking"} | | Vis tenkeblokker | +| {"--port"} | | Port for den lokale serveren (standard til tilfeldig port) | --- @@ -364,12 +373,12 @@ Dette starter en HTTP-server som gir API tilgang til OpenCode-funksjonalitet ute #### Flagg -| Flagg | Beskrivelse | -| ------------ | -------------------------------------------------- | -| `--port` | Port å lytte på | -| `--hostname` | Vertsnavn å lytte på | -| `--mdns` | Aktiver mDNS-oppdagelse | -| `--cors` | Ytterligere nettleseropprinnelse som tillater CORS | +| Flagg | Beskrivelse | +| ---------------------------------------- | -------------------------------------------------- | +| {"--port"} | Port å lytte på | +| {"--hostname"} | Vertsnavn å lytte på | +| {"--mdns"} | Aktiver mDNS-oppdagelse | +| {"--cors"} | Ytterligere nettleseropprinnelse som tillater CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flagg -| Flagg | Kort | Beskrivelse | -| ------------- | ---- | ---------------------------------------- | -| `--max-count` | `-n` | Begrens til N siste økter | -| `--format` | | Utdataformat: tabell eller json (tabell) | +| Flagg | Kort | Beskrivelse | +| ----------------------------------------- | ---- | ---------------------------------------- | +| {"--max-count"} | `-n` | Begrens til N siste økter | +| {"--format"} | | Utdataformat: tabell eller json (tabell) | --- @@ -410,12 +419,12 @@ opencode stats #### Flagg -| Flagg | Beskrivelse | -| ----------- | -------------------------------------------------------------------------------- | -| `--days` | Vis statistikk for de siste N dagene (hele tiden) | -| `--tools` | Antall verktøy som skal vises (alle) | -| `--models` | Vis oversikt over modellbruk (skjult som standard). Gi et tall for å vise topp N | -| `--project` | Filtrer etter prosjekt (alle prosjekter, tom streng: gjeldende prosjekt) | +| Flagg | Beskrivelse | +| --------------------------------------- | -------------------------------------------------------------------------------- | +| {"--days"} | Vis statistikk for de siste N dagene (hele tiden) | +| {"--tools"} | Antall verktøy som skal vises (alle) | +| {"--models"} | Vis oversikt over modellbruk (skjult som standard). Gi et tall for å vise topp N | +| {"--project"} | Filtrer etter prosjekt (alle prosjekter, tom streng: gjeldende prosjekt) | --- @@ -460,12 +469,12 @@ Dette starter en HTTP-server og åpner en nettleser for å få tilgang til OpenC #### Flagg -| Flagg | Beskrivelse | -| ------------ | -------------------------------------------------- | -| `--port` | Port å lytte på | -| `--hostname` | Vertsnavn å lytte på | -| `--mdns` | Aktiver mDNS-oppdagelse | -| `--cors` | Ytterligere nettleseropprinnelse som tillater CORS | +| Flagg | Beskrivelse | +| ---------------------------------------- | -------------------------------------------------- | +| {"--port"} | Port å lytte på | +| {"--hostname"} | Vertsnavn å lytte på | +| {"--mdns"} | Aktiver mDNS-oppdagelse | +| {"--cors"} | Ytterligere nettleseropprinnelse som tillater CORS | --- @@ -481,11 +490,11 @@ Denne kommandoen starter en ACP-server som kommuniserer via stdin/stdout ved å #### Flagg -| Flagg | Beskrivelse | -| ------------ | -------------------- | -| `--cwd` | Arbeidskatalog | -| `--port` | Port å lytte på | -| `--hostname` | Vertsnavn å lytte på | +| Flagg | Beskrivelse | +| ---------------------------------------- | -------------------- | +| {"--cwd"} | Arbeidskatalog | +| {"--port"} | Port å lytte på | +| {"--hostname"} | Vertsnavn å lytte på | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flagg -| Flagg | Kort | Beskrivelse | -| --------------- | ---- | --------------------------------------------- | -| `--keep-config` | `-c` | Behold konfigurasjonsfiler | -| `--keep-data` | `-d` | Behold øktdata og øyeblikksbilder | -| `--dry-run` | | Vis hva som ville blitt fjernet uten å fjerne | -| `--force` | `-f` | Hopp over bekreftelsesforespørsler | +| Flagg | Kort | Beskrivelse | +| ------------------------------------------- | ---- | --------------------------------------------- | +| {"--keep-config"} | `-c` | Behold konfigurasjonsfiler | +| {"--keep-data"} | `-d` | Behold øktdata og øyeblikksbilder | +| {"--dry-run"} | | Vis hva som ville blitt fjernet uten å fjerne | +| {"--force"} | `-f` | Hopp over bekreftelsesforespørsler | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Flagg -| Flagg | Kort | Beskrivelse | -| ---------- | ---- | -------------------------------------------------------------- | -| `--method` | `-m` | Installasjonsmetoden som ble brukt: curl, npm, pnpm, bun, brew | +| Flagg | Kort | Beskrivelse | +| -------------------------------------- | ---- | -------------------------------------------------------------- | +| {"--method"} | `-m` | Installasjonsmetoden som ble brukt: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 OpenCode CLI bruker følgende globale flagg. -| Flagg | Kort | Beskrivelse | -| -------------- | ---- | ----------------------------------- | -| `--help` | `-h` | Vis hjelp | -| `--version` | `-v` | Skriv ut versjonsnummer | -| `--print-logs` | | Skriv ut logger til stderr | -| `--log-level` | | Loggnivå (DEBUG, INFO, WARN, ERROR) | +| Flagg | Kort | Beskrivelse | +| ------------------------------------------ | ---- | ----------------------------------- | +| {"--help"} | `-h` | Vis hjelp | +| {"--version"} | `-v` | Skriv ut versjonsnummer | +| {"--print-logs"} | | Skriv ut logger til stderr | +| {"--log-level"} | | Loggnivå (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/pl/cli.mdx b/packages/web/src/content/docs/pl/cli.mdx index e175870cbf..f2e4ddf9b2 100644 --- a/packages/web/src/content/docs/pl/cli.mdx +++ b/packages/web/src/content/docs/pl/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Flagi -| Flaga | Skrót | Opis | -| ------------ | ----- | ----------------------------------------------------------------------- | -| `--continue` | `-c` | Kontynuuj ostatnią sesję | -| `--session` | `-s` | Identyfikator sesji do kontynuowania | -| `--fork` | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | -| `--prompt` | | Monit do użycia | -| `--model` | `-m` | Model do użycia w formacie dostawca/model | -| `--agent` | | Agent do użycia | -| `--port` | | Port do nasłuchiwania | -| `--hostname` | | Nazwa hosta, do której należy się powiązać | +| Flaga | Skrót | Opis | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------- | +| {"--continue"} | `-c` | Kontynuuj ostatnią sesję | +| {"--session"} | `-s` | Identyfikator sesji do kontynuowania | +| {"--fork"} | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | +| {"--prompt"} | | Monit do użycia | +| {"--model"} | `-m` | Model do użycia w formacie dostawca/model | +| {"--agent"} | | Agent do użycia | +| {"--port"} | | Port do nasłuchiwania | +| {"--hostname"} | | Nazwa hosta, do której należy się powiązać | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Flagi -| Flaga | Skrót | Opis | -| ----------- | ----- | --------------------------------------- | -| `--dir` | | Katalog roboczy, w którym uruchomić TUI | -| `--session` | `-s` | Identyfikator sesji do kontynuowania | +| Flaga | Skrót | Opis | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------- | +| {"--dir"} | | Katalog roboczy, w którym uruchomić TUI | +| {"--continue"} | `-c` | Kontynuuj ostatnią sesję | +| {"--session"} | `-s` | Identyfikator sesji do kontynuowania | +| {"--fork"} | | Rozgałęź sesję podczas kontynuowania (użyj z `--continue` lub `--session`) | +| {"--password"} | `-p` | Hasło uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nazwa użytkownika uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_USERNAME` lub `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Flagi -| Flaga | Opis | -| --------- | ---------------------------------------- | -| `--event` | Zdarzenie GitHub, które wyzwoliło agenta | -| `--token` | Osobisty token dostępu GitHub | +| Flaga | Opis | +| ------------------------------------- | ---------------------------------------- | +| {"--event"} | Zdarzenie GitHub, które wyzwoliło agenta | +| {"--token"} | Osobisty token dostępu GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Flagi -| Flaga | Opis | -| ----------- | ------------------------------------------------------------------------------- | -| `--refresh` | Odśwież pamięć podręczną modeli | -| `--verbose` | Bardziej szczegółowe dane wyjściowe modelu (zawiera metadane, takie jak koszty) | +| Flaga | Opis | +| --------------------------------------- | ------------------------------------------------------------------------------- | +| {"--refresh"} | Odśwież pamięć podręczną modeli | +| {"--verbose"} | Bardziej szczegółowe dane wyjściowe modelu (zawiera metadane, takie jak koszty) | Użyj flagi `--refresh`, aby zaktualizować listę modeli w pamięci podręcznej. Jest to przydatne, gdy dostawca dodał nowe modele, które chcesz zobaczyć w OpenCode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flagi -| Flaga | Skrót | Opis | -| ------------ | ----- | ----------------------------------------------------------------------- | -| `--command` | | Polecenie do uruchomienia, reszta to argumenty | -| `--continue` | `-c` | Kontynuuj ostatnią sesję | -| `--session` | `-s` | Identyfikator sesji do kontynuowania | -| `--fork` | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | -| `--share` | | Udostępnij sesję po zakończeniu | -| `--model` | `-m` | Model do użycia w formacie dostawca/model | -| `--agent` | | Agent do użycia | -| `--file` | `-f` | Pliki do załączenia do wiadomości | -| `--format` | | Format wyjściowy: `default` (sformatowany) lub `json` (surowy JSON) | -| `--title` | | Tytuł sesji (jeśli nie podano, zostanie wygenerowany z promptu) | -| `--attach` | | Dołącz do działającego serwera OpenCode (np. http://localhost:4096) | -| `--port` | | Port dla serwera lokalnego (domyślnie losowy) | +| Flaga | Skrót | Opis | +| ---------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------- | +| {"--command"} | | Polecenie do uruchomienia, reszta to argumenty | +| {"--continue"} | `-c` | Kontynuuj ostatnią sesję | +| {"--session"} | `-s` | Identyfikator sesji do kontynuowania | +| {"--fork"} | | Sklonuj sesję podczas kontynuacji (użyj z `--continue` lub `--session`) | +| {"--share"} | | Udostępnij sesję po zakończeniu | +| {"--model"} | `-m` | Model do użycia w formacie dostawca/model | +| {"--agent"} | | Agent do użycia | +| {"--file"} | `-f` | Pliki do załączenia do wiadomości | +| {"--format"} | | Format wyjściowy: `default` (sformatowany) lub `json` (surowy JSON) | +| {"--title"} | | Tytuł sesji (jeśli nie podano, zostanie wygenerowany z promptu) | +| {"--attach"} | | Dołącz do działającego serwera OpenCode (np. http://localhost:4096) | +| {"--password"} | `-p` | Hasło uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Nazwa użytkownika uwierzytelniania podstawowego (domyślnie `OPENCODE_SERVER_USERNAME` lub `opencode`) | +| {"--dir"} | | Katalog do uruchomienia lub ścieżka na zdalnym serwerze podczas dołączania | +| {"--variant"} | | Wariant modelu (poziom wnioskowania specyficzny dla dostawcy) | +| {"--thinking"} | | Pokaż bloki myślenia | +| {"--port"} | | Port dla serwera lokalnego (domyślnie losowy) | --- @@ -364,12 +373,12 @@ Uruchamia to serwer HTTP, który zapewnia dostęp do API OpenCode bez interfejsu #### Flagi -| Flaga | Opis | -| ------------ | ------------------------------------------ | -| `--port` | Port do nasłuchiwania | -| `--hostname` | Nazwa hosta, do której należy się powiązać | -| `--mdns` | Włącz wykrywanie mDNS | -| `--cors` | Dodatkowe dozwolone źródła CORS | +| Flaga | Opis | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Port do nasłuchiwania | +| {"--hostname"} | Nazwa hosta, do której należy się powiązać | +| {"--mdns"} | Włącz wykrywanie mDNS | +| {"--cors"} | Dodatkowe dozwolone źródła CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Flagi -| Flaga | Skrót | Opis | -| ------------- | ----- | ------------------------------------------ | -| `--max-count` | `-n` | Ogranicz do ostatnich N sesji | -| `--format` | | Format wyjściowy: tabela lub json (tabela) | +| Flaga | Skrót | Opis | +| ----------------------------------------- | ----- | ------------------------------------------ | +| {"--max-count"} | `-n` | Ogranicz do ostatnich N sesji | +| {"--format"} | | Format wyjściowy: tabela lub json (tabela) | --- @@ -410,12 +419,12 @@ opencode stats #### Flagi -| Flaga | Opis | -| ----------- | ------------------------------------------------------------------------------------- | -| `--days` | Pokaż statystyki z ostatnich N dni (domyślnie: cały czas) | -| `--tools` | Pokaż użycie poszczególnych narzędzi (domyślnie: wszystkie) | -| `--models` | Pokaż podział na modele (domyślnie ukryty). Podaj liczbę, aby pokazać N najczęstszych | -| `--project` | Filtruj według projektu (domyślnie: wszystkie projekty, pusty ciąg: bieżący projekt) | +| Flaga | Opis | +| --------------------------------------- | ------------------------------------------------------------------------------------- | +| {"--days"} | Pokaż statystyki z ostatnich N dni (domyślnie: cały czas) | +| {"--tools"} | Pokaż użycie poszczególnych narzędzi (domyślnie: wszystkie) | +| {"--models"} | Pokaż podział na modele (domyślnie ukryty). Podaj liczbę, aby pokazać N najczęstszych | +| {"--project"} | Filtruj według projektu (domyślnie: wszystkie projekty, pusty ciąg: bieżący projekt) | --- @@ -460,12 +469,12 @@ Uruchamia to serwer HTTP i udostępnia OpenCode przez interfejs przeglądarkowy. #### Flagi -| Flaga | Opis | -| ------------ | ------------------------------------------ | -| `--port` | Port do nasłuchiwania | -| `--hostname` | Nazwa hosta, do której należy się powiązać | -| `--mdns` | Włącz wykrywanie mDNS | -| `--cors` | Dodatkowe dozwolone źródła CORS | +| Flaga | Opis | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Port do nasłuchiwania | +| {"--hostname"} | Nazwa hosta, do której należy się powiązać | +| {"--mdns"} | Włącz wykrywanie mDNS | +| {"--cors"} | Dodatkowe dozwolone źródła CORS | --- @@ -481,11 +490,11 @@ Uruchamia serwer ACP, który komunikuje się przez stdin/stdout przy użyciu JSO #### Flagi -| Flaga | Opis | -| ------------ | ------------------------------------------ | -| `--cwd` | Katalog roboczy | -| `--port` | Port do nasłuchiwania | -| `--hostname` | Nazwa hosta, do której należy się powiązać | +| Flaga | Opis | +| ---------------------------------------- | ------------------------------------------ | +| {"--cwd"} | Katalog roboczy | +| {"--port"} | Port do nasłuchiwania | +| {"--hostname"} | Nazwa hosta, do której należy się powiązać | --- @@ -499,12 +508,12 @@ opencode uninstall #### Flagi -| Flaga | Skrót | Opis | -| --------------- | ----- | ----------------------------- | -| `--keep-config` | `-c` | Zachowaj pliki konfiguracyjne | -| `--keep-data` | `-d` | Zachowaj dane sesji i migawki | -| `--dry-run` | | Pokaż co zostanie usunięte | -| `--force` | `-f` | Pomiń monity o potwierdzenie | +| Flaga | Skrót | Opis | +| ------------------------------------------- | ----- | ----------------------------- | +| {"--keep-config"} | `-c` | Zachowaj pliki konfiguracyjne | +| {"--keep-data"} | `-d` | Zachowaj dane sesji i migawki | +| {"--dry-run"} | | Pokaż co zostanie usunięte | +| {"--force"} | `-f` | Pomiń monity o potwierdzenie | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Flagi -| Flaga | Skrót | Opis | -| ---------- | ----- | --------------------------------------------------- | -| `--method` | `-m` | Wymuś metodę instalacji: curl, npm, pnpm, bun, brew | +| Flaga | Skrót | Opis | +| -------------------------------------- | ----- | --------------------------------------------------- | +| {"--method"} | `-m` | Wymuś metodę instalacji: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 Interfejs CLI OpenCode przyjmuje następujące flagi globalne dla każdego polecenia. -| Flaga | Skrót | Opis | -| -------------- | ----- | ------------------------------------------- | -| `--help` | `-h` | Wyświetl pomoc | -| `--version` | `-v` | Wydrukuj numer wersji | -| `--print-logs` | | Drukuj logi na stderr | -| `--log-level` | | Poziom logowania (DEBUG, INFO, WARN, ERROR) | +| Flaga | Skrót | Opis | +| ------------------------------------------ | ----- | ------------------------------------------- | +| {"--help"} | `-h` | Wyświetl pomoc | +| {"--version"} | `-v` | Wydrukuj numer wersji | +| {"--print-logs"} | | Drukuj logi na stderr | +| {"--log-level"} | | Poziom logowania (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/pt-br/cli.mdx b/packages/web/src/content/docs/pt-br/cli.mdx index 78190b3c5d..889626d417 100644 --- a/packages/web/src/content/docs/pt-br/cli.mdx +++ b/packages/web/src/content/docs/pt-br/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Opções -| Flag | Curto | Descrição | -| ------------ | ----- | -------------------------------------------------------------------------- | -| `--continue` | `-c` | Continue a última sessão | -| `--session` | `-s` | ID da sessão para continuar | -| `--fork` | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | -| `--prompt` | | Prompt a ser usado | -| `--model` | `-m` | Modelo a ser usado na forma de provider/model | -| `--agent` | | Agente a ser usado | -| `--port` | | Porta para escutar | -| `--hostname` | | Nome do host para escutar | +| Flag | Curto | Descrição | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Continue a última sessão | +| {"--session"} | `-s` | ID da sessão para continuar | +| {"--fork"} | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | +| {"--prompt"} | | Prompt a ser usado | +| {"--model"} | `-m` | Modelo a ser usado na forma de provider/model | +| {"--agent"} | | Agente a ser usado | +| {"--port"} | | Porta para escutar | +| {"--hostname"} | | Nome do host para escutar | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Opções -| Flag | Curto | Descrição | -| ----------- | ----- | ---------------------------------------- | -| `--dir` | | Diretório de trabalho para iniciar o TUI | -| `--session` | `-s` | ID da sessão para continuar | +| Flag | Curto | Descrição | +| ---------------------------------------- | ----- | --------------------------------------------------------------------------------- | +| {"--dir"} | | Diretório de trabalho para iniciar o TUI | +| {"--continue"} | `-c` | Continuar a última sessão | +| {"--session"} | `-s` | ID da sessão para continuar | +| {"--fork"} | | Bifurcar a sessão ao continuar (use com `--continue` ou `--session`) | +| {"--password"} | `-p` | Senha de autenticação básica (padrão: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuário de autenticação básica (padrão: `OPENCODE_SERVER_USERNAME` ou `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Opções -| Flag | Descrição | -| --------- | ------------------------------------------------ | -| `--event` | Evento simulado do GitHub para executar o agente | -| `--token` | Token de acesso pessoal do GitHub | +| Flag | Descrição | +| ------------------------------------- | ------------------------------------------------ | +| {"--event"} | Evento simulado do GitHub para executar o agente | +| {"--token"} | Token de acesso pessoal do GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Opções -| Flag | Descrição | -| ----------- | --------------------------------------------------------------------- | -| `--refresh` | Atualiza o cache de modelos a partir do models.dev | -| `--verbose` | Use uma saída de modelo mais detalhada (inclui metadados como custos) | +| Flag | Descrição | +| --------------------------------------- | --------------------------------------------------------------------- | +| {"--refresh"} | Atualiza o cache de modelos a partir do models.dev | +| {"--verbose"} | Use uma saída de modelo mais detalhada (inclui metadados como custos) | Use a flag `--refresh` para atualizar a lista de modelos em cache. Isso é útil quando novos modelos foram adicionados a um provedor e você deseja vê-los no opencode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explique async/await em JavaScript" #### Opções -| Flag | Curto | Descrição | -| ------------ | ----- | ----------------------------------------------------------------------------- | -| `--command` | | O comando a ser executado, use mensagem para argumentos | -| `--continue` | `-c` | Continue a última sessão | -| `--session` | `-s` | ID da sessão para continuar | -| `--fork` | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | -| `--share` | | Compartilhe a sessão | -| `--model` | `-m` | Modelo a ser usado na forma de provider/model | -| `--agent` | | Agente a ser usado | -| `--file` | `-f` | Arquivo(s) a serem anexados à mensagem | -| `--format` | | Formato: padrão (formatado) ou json (eventos JSON brutos) | -| `--title` | | Título para a sessão (usa o prompt truncado se nenhum valor for fornecido) | -| `--attach` | | Anexe a um servidor opencode em execução (por exemplo, http://localhost:4096) | -| `--port` | | Porta para o servidor local (padrão para porta aleatória) | +| Flag | Curto | Descrição | +| ---------------------------------------- | ----- | --------------------------------------------------------------------------------- | +| {"--command"} | | O comando a ser executado, use mensagem para argumentos | +| {"--continue"} | `-c` | Continue a última sessão | +| {"--session"} | `-s` | ID da sessão para continuar | +| {"--fork"} | | Criar um fork da sessão ao continuar (use com `--continue` ou `--session`) | +| {"--share"} | | Compartilhe a sessão | +| {"--model"} | `-m` | Modelo a ser usado na forma de provider/model | +| {"--agent"} | | Agente a ser usado | +| {"--file"} | `-f` | Arquivo(s) a serem anexados à mensagem | +| {"--format"} | | Formato: padrão (formatado) ou json (eventos JSON brutos) | +| {"--title"} | | Título para a sessão (usa o prompt truncado se nenhum valor for fornecido) | +| {"--attach"} | | Anexe a um servidor opencode em execução (por exemplo, http://localhost:4096) | +| {"--password"} | `-p` | Senha de autenticação básica (padrão: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Usuário de autenticação básica (padrão: `OPENCODE_SERVER_USERNAME` ou `opencode`) | +| {"--dir"} | | Diretório de execução, ou caminho no servidor remoto ao anexar | +| {"--variant"} | | Variante do modelo (nível de raciocínio específico do provedor) | +| {"--thinking"} | | Mostrar blocos de pensamento | +| {"--port"} | | Porta para o servidor local (padrão para porta aleatória) | --- @@ -364,12 +373,12 @@ Isso inicia um servidor HTTP que fornece acesso à funcionalidade do opencode se #### Opções -| Flag | Descrição | -| ------------ | ----------------------------------------------------- | -| `--port` | Porta para escutar | -| `--hostname` | Nome do host para escutar | -| `--mdns` | Habilitar descoberta mDNS | -| `--cors` | Origem(ns) de navegador adicionais para permitir CORS | +| Flag | Descrição | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Porta para escutar | +| {"--hostname"} | Nome do host para escutar | +| {"--mdns"} | Habilitar descoberta mDNS | +| {"--cors"} | Origem(ns) de navegador adicionais para permitir CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Opções -| Flag | Curto | Descrição | -| ------------- | ----- | ----------------------------------------- | -| `--max-count` | `-n` | Limitar às N sessões mais recentes | -| `--format` | | Formato de saída: tabela ou json (tabela) | +| Flag | Curto | Descrição | +| ----------------------------------------- | ----- | ----------------------------------------- | +| {"--max-count"} | `-n` | Limitar às N sessões mais recentes | +| {"--format"} | | Formato de saída: tabela ou json (tabela) | --- @@ -410,12 +419,12 @@ opencode stats #### Opções -| Flag | Descrição | -| ----------- | ---------------------------------------------------------------------------------------------------- | -| `--days` | Mostre estatísticas dos últimos N dias (todo o tempo) | -| `--tools` | Número de ferramentas a serem mostradas (todas) | -| `--models` | Mostre a divisão do uso de modelos (oculto por padrão). Passe um número para mostrar os N principais | -| `--project` | Filtrar por projeto (todos os projetos, string vazia: projeto atual) | +| Flag | Descrição | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| {"--days"} | Mostre estatísticas dos últimos N dias (todo o tempo) | +| {"--tools"} | Número de ferramentas a serem mostradas (todas) | +| {"--models"} | Mostre a divisão do uso de modelos (oculto por padrão). Passe um número para mostrar os N principais | +| {"--project"} | Filtrar por projeto (todos os projetos, string vazia: projeto atual) | --- @@ -460,12 +469,12 @@ Isso inicia um servidor HTTP e abre um navegador para acessar o opencode atravé #### Opções -| Flag | Descrição | -| ------------ | ----------------------------------------------------- | -| `--port` | Porta para escutar | -| `--hostname` | Nome do host para escutar | -| `--mdns` | Habilitar descoberta mDNS | -| `--cors` | Origem(ns) de navegador adicionais para permitir CORS | +| Flag | Descrição | +| ---------------------------------------- | ----------------------------------------------------- | +| {"--port"} | Porta para escutar | +| {"--hostname"} | Nome do host para escutar | +| {"--mdns"} | Habilitar descoberta mDNS | +| {"--cors"} | Origem(ns) de navegador adicionais para permitir CORS | --- @@ -481,11 +490,11 @@ Este comando inicia um servidor ACP que se comunica via stdin/stdout usando nd-J #### Opções -| Flag | Descrição | -| ------------ | ------------------------- | -| `--cwd` | Diretório de trabalho | -| `--port` | Porta para escutar | -| `--hostname` | Nome do host para escutar | +| Flag | Descrição | +| ---------------------------------------- | ------------------------- | +| {"--cwd"} | Diretório de trabalho | +| {"--port"} | Porta para escutar | +| {"--hostname"} | Nome do host para escutar | --- @@ -499,12 +508,12 @@ opencode uninstall #### Opções -| Flag | Curto | Descrição | -| --------------- | ----- | ---------------------------------------- | -| `--keep-config` | `-c` | Manter arquivos de configuração | -| `--keep-data` | `-d` | Manter dados de sessão e snapshots | -| `--dry-run` | | Mostrar o que seria removido sem remover | -| `--force` | `-f` | Pular prompts de confirmação | +| Flag | Curto | Descrição | +| ------------------------------------------- | ----- | ---------------------------------------- | +| {"--keep-config"} | `-c` | Manter arquivos de configuração | +| {"--keep-data"} | `-d` | Manter dados de sessão e snapshots | +| {"--dry-run"} | | Mostrar o que seria removido sem remover | +| {"--force"} | `-f` | Pular prompts de confirmação | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Opções -| Flag | Curto | Descrição | -| ---------- | ----- | ---------------------------------------------------------------- | -| `--method` | `-m` | O método de instalação que foi usado; curl, npm, pnpm, bun, brew | +| Flag | Curto | Descrição | +| -------------------------------------- | ----- | ---------------------------------------------------------------- | +| {"--method"} | `-m` | O método de instalação que foi usado; curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 A CLI do opencode aceita as seguintes flags globais. -| Flag | Curto | Descrição | -| -------------- | ----- | --------------------------------------- | -| `--help` | `-h` | Exibir ajuda | -| `--version` | `-v` | Imprimir número da versão | -| `--print-logs` | | Imprimir logs no stderr | -| `--log-level` | | Nível de log (DEBUG, INFO, WARN, ERROR) | +| Flag | Curto | Descrição | +| ------------------------------------------ | ----- | --------------------------------------- | +| {"--help"} | `-h` | Exibir ajuda | +| {"--version"} | `-v` | Imprimir número da versão | +| {"--print-logs"} | | Imprimir logs no stderr | +| {"--log-level"} | | Nível de log (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/ru/cli.mdx b/packages/web/src/content/docs/ru/cli.mdx index f5aeee256f..5f52f3d7f0 100644 --- a/packages/web/src/content/docs/ru/cli.mdx +++ b/packages/web/src/content/docs/ru/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Флаги -| Флаг | Короткий | Описание | -| ------------ | -------- | ----------------------------------------------------------------------------- | -| `--continue` | `-c` | Продолжить последний сеанс | -| `--session` | `-s` | Идентификатор сеанса для продолжения | -| `--fork` | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | -| `--prompt` | | Промпт для использования | -| `--model` | `-m` | Модель для использования в виде поставщика/модели. | -| `--agent` | | Агент для использования | -| `--port` | | Порт для прослушивания | -| `--hostname` | | Имя хоста для прослушивания | +| Флаг | Короткий | Описание | +| ---------------------------------------- | -------- | ----------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Продолжить последний сеанс | +| {"--session"} | `-s` | Идентификатор сеанса для продолжения | +| {"--fork"} | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | +| {"--prompt"} | | Промпт для использования | +| {"--model"} | `-m` | Модель для использования в виде поставщика/модели. | +| {"--agent"} | | Агент для использования | +| {"--port"} | | Порт для прослушивания | +| {"--hostname"} | | Имя хоста для прослушивания | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Флаги -| Флаг | Короткий | Описание | -| ----------- | -------- | ------------------------------------ | -| `--dir` | | Рабочий каталог для запуска TUI | -| `--session` | `-s` | Идентификатор сеанса для продолжения | +| Флаг | Короткий | Описание | +| ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------ | +| {"--dir"} | | Рабочий каталог для запуска TUI | +| {"--continue"} | `-c` | Продолжить последний сеанс | +| {"--session"} | `-s` | Идентификатор сеанса для продолжения | +| {"--fork"} | | Создать ответвление сеанса при продолжении (используйте с `--continue` или `--session`) | +| {"--password"} | `-p` | Пароль базовой аутентификации (по умолчанию `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Имя пользователя базовой аутентификации (по умолчанию `OPENCODE_SERVER_USERNAME` или `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Флаги -| Флаг | Описание | -| --------- | --------------------------------------------- | -| `--event` | Имитирующее событие GitHub для запуска агента | -| `--token` | Токен личного доступа GitHub | +| Флаг | Описание | +| ------------------------------------- | --------------------------------------------- | +| {"--event"} | Имитирующее событие GitHub для запуска агента | +| {"--token"} | Токен личного доступа GitHub | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Флаги -| Флаг | Описание | -| ----------- | --------------------------------------------------------------------------------- | -| `--refresh` | Обновите кеш моделей на сайте models.dev. | -| `--verbose` | Используйте более подробный вывод модели (включая метаданные, такие как затраты). | +| Флаг | Описание | +| --------------------------------------- | --------------------------------------------------------------------------------- | +| {"--refresh"} | Обновите кеш моделей на сайте models.dev. | +| {"--verbose"} | Используйте более подробный вывод модели (включая метаданные, такие как затраты). | Используйте флаг `--refresh` для обновления списка кэшированных моделей. Это полезно, когда к поставщику добавлены новые модели и вы хотите увидеть их в opencode. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Флаги -| Флаг | Короткий | Описание | -| ------------ | -------- | -------------------------------------------------------------------------------- | -| `--command` | | Команда для запуска, используйте сообщение для аргументов | -| `--continue` | `-c` | Продолжить последний сеанс | -| `--session` | `-s` | Идентификатор сеанса для продолжения | -| `--fork` | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | -| `--share` | | Поделиться сеансом | -| `--model` | `-m` | Модель для использования в виде поставщика/модели. | -| `--agent` | | Агент для использования | -| `--file` | `-f` | Файл(ы) для прикрепления к сообщению | -| `--format` | | Формат: по умолчанию (отформатированный) или json (необработанные события JSON). | -| `--title` | | Название сеанса (использует усеченное приглашение, если значение не указано) | -| `--attach` | | Подключитесь к работающему серверу opencode (например, http://localhost:4096) | -| `--port` | | Порт локального сервера (по умолчанию случайный порт) | +| Флаг | Короткий | Описание | +| ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------ | +| {"--command"} | | Команда для запуска, используйте сообщение для аргументов | +| {"--continue"} | `-c` | Продолжить последний сеанс | +| {"--session"} | `-s` | Идентификатор сеанса для продолжения | +| {"--fork"} | | Разветвить сеанс при продолжении (используйте с `--continue` или `--session`) | +| {"--share"} | | Поделиться сеансом | +| {"--model"} | `-m` | Модель для использования в виде поставщика/модели. | +| {"--agent"} | | Агент для использования | +| {"--file"} | `-f` | Файл(ы) для прикрепления к сообщению | +| {"--format"} | | Формат: по умолчанию (отформатированный) или json (необработанные события JSON). | +| {"--title"} | | Название сеанса (использует усеченное приглашение, если значение не указано) | +| {"--attach"} | | Подключитесь к работающему серверу opencode (например, http://localhost:4096) | +| {"--password"} | `-p` | Пароль базовой аутентификации (по умолчанию `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Имя пользователя базовой аутентификации (по умолчанию `OPENCODE_SERVER_USERNAME` или `opencode`) | +| {"--dir"} | | Каталог для выполнения или путь на удалённом сервере при подключении | +| {"--variant"} | | Вариант модели (уровень рассуждений для провайдера) | +| {"--thinking"} | | Показать блоки размышлений | +| {"--port"} | | Порт локального сервера (по умолчанию случайный порт) | --- @@ -364,12 +373,12 @@ opencode serve #### Флаги -| Флаг | Описание | -| ------------ | ------------------------------------------------------------- | -| `--port` | Порт для прослушивания | -| `--hostname` | Имя хоста для прослушивания | -| `--mdns` | Включить обнаружение mDNS | -| `--cors` | Дополнительные источники браузера, позволяющие разрешить CORS | +| Флаг | Описание | +| ---------------------------------------- | ------------------------------------------------------------- | +| {"--port"} | Порт для прослушивания | +| {"--hostname"} | Имя хоста для прослушивания | +| {"--mdns"} | Включить обнаружение mDNS | +| {"--cors"} | Дополнительные источники браузера, позволяющие разрешить CORS | --- @@ -393,10 +402,10 @@ opencode session list ##### Флаги -| Флаг | Короткий | Описание | -| ------------- | -------- | ----------------------------------------- | -| `--max-count` | `-n` | Ограничить N последних сеансов. | -| `--format` | | Формат вывода: таблица или json (таблица) | +| Флаг | Короткий | Описание | +| ----------------------------------------- | -------- | ----------------------------------------- | +| {"--max-count"} | `-n` | Ограничить N последних сеансов. | +| {"--format"} | | Формат вывода: таблица или json (таблица) | --- @@ -410,12 +419,12 @@ opencode stats #### Флаги -| Флаг | Описание | -| ----------- | ---------------------------------------------------------------------------------------------------------- | -| `--days` | Показать статистику за последние N дней (все время) | -| `--tools` | Количество инструментов для отображения (все) | -| `--models` | Показать разбивку по использованию модели (по умолчанию скрыто). Передайте номер, чтобы показать верхнюю N | -| `--project` | Фильтровать по проекту (все проекты, пустая строка: текущий проект) | +| Флаг | Описание | +| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| {"--days"} | Показать статистику за последние N дней (все время) | +| {"--tools"} | Количество инструментов для отображения (все) | +| {"--models"} | Показать разбивку по использованию модели (по умолчанию скрыто). Передайте номер, чтобы показать верхнюю N | +| {"--project"} | Фильтровать по проекту (все проекты, пустая строка: текущий проект) | --- @@ -460,12 +469,12 @@ opencode web #### Флаги -| Флаг | Описание | -| ------------ | ------------------------------------------------------------- | -| `--port` | Порт для прослушивания | -| `--hostname` | Имя хоста для прослушивания | -| `--mdns` | Включить обнаружение mDNS | -| `--cors` | Дополнительные источники браузера, позволяющие разрешить CORS | +| Флаг | Описание | +| ---------------------------------------- | ------------------------------------------------------------- | +| {"--port"} | Порт для прослушивания | +| {"--hostname"} | Имя хоста для прослушивания | +| {"--mdns"} | Включить обнаружение mDNS | +| {"--cors"} | Дополнительные источники браузера, позволяющие разрешить CORS | --- @@ -481,11 +490,11 @@ opencode acp #### Флаги -| Флаг | Описание | -| ------------ | --------------------------- | -| `--cwd` | Рабочий каталог | -| `--port` | Порт для прослушивания | -| `--hostname` | Имя хоста для прослушивания | +| Флаг | Описание | +| ---------------------------------------- | --------------------------- | +| {"--cwd"} | Рабочий каталог | +| {"--port"} | Порт для прослушивания | +| {"--hostname"} | Имя хоста для прослушивания | --- @@ -499,12 +508,12 @@ opencode uninstall #### Флаги -| Флаг | Короткий | Описание | -| --------------- | -------- | ------------------------------------------ | -| `--keep-config` | `-c` | Сохраняйте файлы конфигурации | -| `--keep-data` | `-d` | Храните данные сеанса и снимки | -| `--dry-run` | | Покажите, что было бы удалено без удаления | -| `--force` | `-f` | Пропустить запросы подтверждения | +| Флаг | Короткий | Описание | +| ------------------------------------------- | -------- | ------------------------------------------ | +| {"--keep-config"} | `-c` | Сохраняйте файлы конфигурации | +| {"--keep-data"} | `-d` | Храните данные сеанса и снимки | +| {"--dry-run"} | | Покажите, что было бы удалено без удаления | +| {"--force"} | `-f` | Пропустить запросы подтверждения | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Флаги -| Флаг | Короткий | Описание | -| ---------- | -------- | --------------------------------------------------------- | -| `--method` | `-m` | Используемый метод установки: local, npm, pnpm, bun, brew | +| Флаг | Короткий | Описание | +| -------------------------------------- | -------- | --------------------------------------------------------- | +| {"--method"} | `-m` | Используемый метод установки: local, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 CLI opencode принимает следующие глобальные флаги. -| Флаг | Короткий | Описание | -| -------------- | -------- | ------------------------------------------ | -| `--help` | `-h` | Отобразить справку | -| `--version` | `-v` | Распечатать номер версии | -| `--print-logs` | | Печать журналов в stderr | -| `--log-level` | | Уровень журнала (DEBUG, INFO, WARN, ERROR) | +| Флаг | Короткий | Описание | +| ------------------------------------------ | -------- | ------------------------------------------ | +| {"--help"} | `-h` | Отобразить справку | +| {"--version"} | `-v` | Распечатать номер версии | +| {"--print-logs"} | | Печать журналов в stderr | +| {"--log-level"} | | Уровень журнала (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/th/cli.mdx b/packages/web/src/content/docs/th/cli.mdx index 4b2db9d988..d987228464 100644 --- a/packages/web/src/content/docs/th/cli.mdx +++ b/packages/web/src/content/docs/th/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ------------ | ---- | ---------------------------------------------------------- | -| `--continue` | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | -| `--session` | `-s` | ID เซสชันเพื่อดำเนินการต่อ | -| `--fork` | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | -| `--prompt` | | พรอมต์เริ่มต้นที่จะใช้ | -| `--model` | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | -| `--agent` | | เอเจนต์ที่จะใช้ | -| `--port` | | พอร์ตที่จะฟัง | -| `--hostname` | | ชื่อโฮสต์ที่จะฟัง | +| แฟล็ก | สั้น | คำอธิบาย | +| ---------------------------------------- | ---- | ---------------------------------------------------------- | +| {"--continue"} | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | +| {"--session"} | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| {"--fork"} | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | +| {"--prompt"} | | พรอมต์เริ่มต้นที่จะใช้ | +| {"--model"} | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | +| {"--agent"} | | เอเจนต์ที่จะใช้ | +| {"--port"} | | พอร์ตที่จะฟัง | +| {"--hostname"} | | ชื่อโฮสต์ที่จะฟัง | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ----------- | ---- | -------------------------------------- | -| `--dir` | | ไดเร็กทอรีการทำงานเพื่อเริ่มต้น TUI ใน | -| `--session` | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| แฟล็ก | สั้น | คำอธิบาย | +| ---------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | +| {"--dir"} | | ไดเร็กทอรีการทำงานเพื่อเริ่มต้น TUI ใน | +| {"--continue"} | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | +| {"--session"} | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| {"--fork"} | | แยกเซสชันเมื่อดำเนินการต่อ (ใช้กับ `--continue` หรือ `--session`) | +| {"--password"} | `-p` | รหัสผ่านการยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | ชื่อผู้ใช้การยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_USERNAME` หรือ `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### แฟล็ก -| แฟล็ก | คำอธิบาย | -| --------- | -------------------------------------- | -| `--event` | เหตุการณ์ GitHub เพื่อทริกเกอร์เอเจนต์ | -| `--token` | GitHub token | +| แฟล็ก | คำอธิบาย | +| ------------------------------------- | -------------------------------------- | +| {"--event"} | เหตุการณ์ GitHub เพื่อทริกเกอร์เอเจนต์ | +| {"--token"} | GitHub token | --- @@ -296,11 +300,11 @@ opencode models anthropic #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ----------- | ------------------------------------------------------ | -| `--refresh` | รีเฟรชแคชโมเดลจาก models.dev | -| `--verbose` | แสดงรายละเอียดโมเดลเพิ่มเติม (รวมข้อมูลเมตาเช่นต้นทุน) | -| `--json` | แสดงผลลัพธ์เป็น JSON | +| แฟล็ก | คำอธิบาย | +| --------------------------------------- | ------------------------------------------------------ | +| {"--refresh"} | รีเฟรชแคชโมเดลจาก models.dev | +| {"--verbose"} | แสดงรายละเอียดโมเดลเพิ่มเติม (รวมข้อมูลเมตาเช่นต้นทุน) | +| {"--json"} | แสดงผลลัพธ์เป็น JSON | ใช้แฟล็ก `--refresh` เพื่ออัปเดตรายการโมเดลที่แคชไว้ มีประโยชน์เมื่อมีการเพิ่มโมเดลใหม่ให้กับผู้ให้บริการและคุณต้องการเห็นใน OpenCode @@ -336,20 +340,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ------------ | ---- | ---------------------------------------------------------------------- | -| `--command` | | คำสั่งที่จะรัน (ใช้ส่วนที่เหลือของ args เป็นอาร์กิวเมนต์) | -| `--continue` | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | -| `--session` | `-s` | ID เซสชันเพื่อดำเนินการต่อ | -| `--fork` | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | -| `--share` | | สร้างลิงก์แชร์สำหรับเซสชัน | -| `--model` | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | -| `--agent` | | เอเจนต์ที่จะใช้ | -| `--file` | `-f` | แนบไฟล์ไปกับข้อความ | -| `--format` | | รูปแบบเอาต์พุต: text (จัดรูปแบบ) หรือ json (JSON ดิบ) | -| `--title` | | ชื่อสำหรับเซสชัน (หากไม่ได้ระบุ จะสร้างจากพรอมต์) | -| `--attach` | | แนบไปกับเซิร์ฟเวอร์ opencode ที่ทำงานอยู่ (เช่น http://localhost:4096) | -| `--port` | | พอร์ตสำหรับเซิร์ฟเวอร์ภายในเครื่อง (หากไม่ได้ระบุ จะใช้พอร์ตสุ่ม) | +| แฟล็ก | สั้น | คำอธิบาย | +| ---------------------------------------- | ---- | ------------------------------------------------------------------------------------------- | +| {"--command"} | | คำสั่งที่จะรัน (ใช้ส่วนที่เหลือของ args เป็นอาร์กิวเมนต์) | +| {"--continue"} | `-c` | ดำเนินการต่อจากเซสชันล่าสุด | +| {"--session"} | `-s` | ID เซสชันเพื่อดำเนินการต่อ | +| {"--fork"} | | แยกเซสชันเมื่อทำต่อ (ใช้กับ `--continue` หรือ `--session`) | +| {"--share"} | | สร้างลิงก์แชร์สำหรับเซสชัน | +| {"--model"} | `-m` | โมเดลที่จะใช้ในรูปแบบ provider/model | +| {"--agent"} | | เอเจนต์ที่จะใช้ | +| {"--file"} | `-f` | แนบไฟล์ไปกับข้อความ | +| {"--format"} | | รูปแบบเอาต์พุต: text (จัดรูปแบบ) หรือ json (JSON ดิบ) | +| {"--title"} | | ชื่อสำหรับเซสชัน (หากไม่ได้ระบุ จะสร้างจากพรอมต์) | +| {"--attach"} | | แนบไปกับเซิร์ฟเวอร์ opencode ที่ทำงานอยู่ (เช่น http://localhost:4096) | +| {"--password"} | `-p` | รหัสผ่านการยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | ชื่อผู้ใช้การยืนยันตัวตนพื้นฐาน (ค่าเริ่มต้นคือ `OPENCODE_SERVER_USERNAME` หรือ `opencode`) | +| {"--dir"} | | ไดเร็กทอรีสำหรับรัน หรือเส้นทางบนเซิร์ฟเวอร์ระยะไกลเมื่อแนบ | +| {"--variant"} | | ตัวแปรโมเดล (ระดับการใช้เหตุผลเฉพาะผู้ให้บริการ) | +| {"--thinking"} | | แสดงบล็อกความคิด | +| {"--port"} | | พอร์ตสำหรับเซิร์ฟเวอร์ภายในเครื่อง (หากไม่ได้ระบุ จะใช้พอร์ตสุ่ม) | --- @@ -365,12 +374,12 @@ opencode serve #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ------------ | -------------------------------------- | -| `--port` | พอร์ตที่จะฟัง | -| `--hostname` | ชื่อโฮสต์ที่จะฟัง | -| `--mdns` | เปิดใช้งานการค้นหา mDNS | -| `--cors` | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | +| แฟล็ก | คำอธิบาย | +| ---------------------------------------- | -------------------------------------- | +| {"--port"} | พอร์ตที่จะฟัง | +| {"--hostname"} | ชื่อโฮสต์ที่จะฟัง | +| {"--mdns"} | เปิดใช้งานการค้นหา mDNS | +| {"--cors"} | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | --- @@ -394,10 +403,10 @@ opencode session list ##### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ------------- | ---- | ----------------------------- | -| `--max-count` | `-n` | จำกัดการแสดงผล N รายการล่าสุด | -| `--format` | | รูปแบบ table หรือ json | +| แฟล็ก | สั้น | คำอธิบาย | +| ----------------------------------------- | ---- | ----------------------------- | +| {"--max-count"} | `-n` | จำกัดการแสดงผล N รายการล่าสุด | +| {"--format"} | | รูปแบบ table หรือ json | --- @@ -411,12 +420,12 @@ opencode stats #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ----------- | ---------------------------------------------------- | -| `--days` | แสดงสถิติของ N วันที่ผ่านมา (ค่าเริ่มต้น: ตลอดเวลา) | -| `--tools` | แสดงสถิติการใช้เครื่องมือ | -| `--models` | แสดงรายละเอียดการใช้งานโมเดล (ซ่อนไว้ตามค่าเริ่มต้น) | -| `--project` | กรองตามโครงการ (ค่าเริ่มต้น: โครงการปัจจุบัน) | +| แฟล็ก | คำอธิบาย | +| --------------------------------------- | ---------------------------------------------------- | +| {"--days"} | แสดงสถิติของ N วันที่ผ่านมา (ค่าเริ่มต้น: ตลอดเวลา) | +| {"--tools"} | แสดงสถิติการใช้เครื่องมือ | +| {"--models"} | แสดงรายละเอียดการใช้งานโมเดล (ซ่อนไว้ตามค่าเริ่มต้น) | +| {"--project"} | กรองตามโครงการ (ค่าเริ่มต้น: โครงการปัจจุบัน) | --- @@ -461,12 +470,12 @@ opencode web #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ------------ | -------------------------------------- | -| `--port` | พอร์ตที่จะฟัง | -| `--hostname` | ชื่อโฮสต์ที่จะฟัง | -| `--mdns` | เปิดใช้งานการค้นหา mDNS | -| `--cors` | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | +| แฟล็ก | คำอธิบาย | +| ---------------------------------------- | -------------------------------------- | +| {"--port"} | พอร์ตที่จะฟัง | +| {"--hostname"} | ชื่อโฮสต์ที่จะฟัง | +| {"--mdns"} | เปิดใช้งานการค้นหา mDNS | +| {"--cors"} | ต้นกำเนิดเพิ่มเติมที่อนุญาตสำหรับ CORS | --- @@ -482,11 +491,11 @@ opencode acp #### แฟล็ก -| แฟล็ก | คำอธิบาย | -| ------------ | ------------------ | -| `--cwd` | ไดเร็กทอรีการทำงาน | -| `--port` | พอร์ตที่จะฟัง | -| `--hostname` | ชื่อโฮสต์ที่จะฟัง | +| แฟล็ก | คำอธิบาย | +| ---------------------------------------- | ------------------ | +| {"--cwd"} | ไดเร็กทอรีการทำงาน | +| {"--port"} | พอร์ตที่จะฟัง | +| {"--hostname"} | ชื่อโฮสต์ที่จะฟัง | --- @@ -500,12 +509,12 @@ opencode uninstall #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| --------------- | ---- | ----------------------------------- | -| `--keep-config` | `-c` | เก็บไฟล์การกำหนดค่าไว้ | -| `--keep-data` | `-d` | เก็บไฟล์ข้อมูล (เซสชันและสแน็ปช็อต) | -| `--dry-run` | | แสดงสิ่งที่จะลบออกโดยไม่ต้องทำจริง | -| `--force` | `-f` | บังคับลบโดยไม่มีการแจ้งเตือน | +| แฟล็ก | สั้น | คำอธิบาย | +| ------------------------------------------- | ---- | ----------------------------------- | +| {"--keep-config"} | `-c` | เก็บไฟล์การกำหนดค่าไว้ | +| {"--keep-data"} | `-d` | เก็บไฟล์ข้อมูล (เซสชันและสแน็ปช็อต) | +| {"--dry-run"} | | แสดงสิ่งที่จะลบออกโดยไม่ต้องทำจริง | +| {"--force"} | `-f` | บังคับลบโดยไม่มีการแจ้งเตือน | --- @@ -531,9 +540,9 @@ opencode upgrade v0.1.48 #### แฟล็ก -| แฟล็ก | สั้น | คำอธิบาย | -| ---------- | ---- | ----------------------------------------------- | -| `--method` | `-m` | วิธีการติดตั้งที่ใช้ curl, npm, pnpm, bun, brew | +| แฟล็ก | สั้น | คำอธิบาย | +| -------------------------------------- | ---- | ----------------------------------------------- | +| {"--method"} | `-m` | วิธีการติดตั้งที่ใช้ curl, npm, pnpm, bun, brew | --- @@ -541,12 +550,12 @@ opencode upgrade v0.1.48 OpenCode CLI ยอมรับแฟล็กสากลต่อไปนี้สำหรับทุกคำสั่ง -| แฟล็ก | สั้น | คำอธิบาย | -| -------------- | ---- | ----------------------------------------- | -| `--help` | `-h` | แสดงความช่วยเหลือ | -| `--version` | `-v` | พิมพ์เวอร์ชัน | -| `--print-logs` | | พิมพ์บันทึกไปยัง stderr | -| `--log-level` | | ระดับการบันทึก (DEBUG, INFO, WARN, ERROR) | +| แฟล็ก | สั้น | คำอธิบาย | +| ------------------------------------------ | ---- | ----------------------------------------- | +| {"--help"} | `-h` | แสดงความช่วยเหลือ | +| {"--version"} | `-v` | พิมพ์เวอร์ชัน | +| {"--print-logs"} | | พิมพ์บันทึกไปยัง stderr | +| {"--log-level"} | | ระดับการบันทึก (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/tr/cli.mdx b/packages/web/src/content/docs/tr/cli.mdx index 75ecca9926..25b74ecfe4 100644 --- a/packages/web/src/content/docs/tr/cli.mdx +++ b/packages/web/src/content/docs/tr/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### Bayraklar -| Bayrak | Kısa | Açıklama | -| ------------ | ---- | --------------------------------------------------------------------------- | -| `--continue` | `-c` | Son oturuma devam et | -| `--session` | `-s` | Devam edecek oturum kimliği | -| `--fork` | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | -| `--prompt` | | Kullanılacak prompt | -| `--model` | `-m` | provider/model biçiminde kullanılacak model | -| `--agent` | | Kullanılacak agent | -| `--port` | | Dinlenecek port | -| `--hostname` | | Dinlenecek host adı | +| Bayrak | Kısa | Açıklama | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------- | +| {"--continue"} | `-c` | Son oturuma devam et | +| {"--session"} | `-s` | Devam edecek oturum kimliği | +| {"--fork"} | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | +| {"--prompt"} | | Kullanılacak prompt | +| {"--model"} | `-m` | provider/model biçiminde kullanılacak model | +| {"--agent"} | | Kullanılacak agent | +| {"--port"} | | Dinlenecek port | +| {"--hostname"} | | Dinlenecek host adı | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### Bayraklar -| Bayrak | Kısa | Tanım | -| ----------- | ---- | ------------------------------------ | -| `--dir` | | TUI'yi başlatmak için çalışma dizini | -| `--session` | `-s` | Devam edecek oturum açma bilgileri | +| Bayrak | Kısa | Tanım | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------------------- | +| {"--dir"} | | TUI'yi başlatmak için çalışma dizini | +| {"--continue"} | `-c` | Son oturuma devam et | +| {"--session"} | `-s` | Devam edecek oturum açma bilgileri | +| {"--fork"} | | Devam ederken oturumu çatalla (`--continue` veya `--session` ile kullanın) | +| {"--password"} | `-p` | Temel kimlik doğrulama parolası (varsayılan: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Temel kimlik doğrulama kullanıcı adı (varsayılan: `OPENCODE_SERVER_USERNAME` veya `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### Bayraklar -| Bayrak | Açıklama | -| --------- | ------------------------------------------- | -| `--event` | Aracıyı çalıştırmak için GitHub sahte olayı | -| `--token` | GitHub personal access token | +| Bayrak | Açıklama | +| ------------------------------------- | ------------------------------------------- | +| {"--event"} | Aracıyı çalıştırmak için GitHub sahte olayı | +| {"--token"} | GitHub personal access token | --- @@ -296,10 +300,10 @@ opencode models anthropic #### Bayraklar -| Bayrak | Tanım | -| ----------- | --------------------------------------------------------------------------- | -| `--refresh` | Modeller.dev'den model önbelleğini yenileyin | -| `--verbose` | Daha ayrıntılı model çıktısı kullanın (maliyetler gibi meta veriler içerir) | +| Bayrak | Tanım | +| --------------------------------------- | --------------------------------------------------------------------------- | +| {"--refresh"} | Modeller.dev'den model önbelleğini yenileyin | +| {"--verbose"} | Daha ayrıntılı model çıktısı kullanın (maliyetler gibi meta veriler içerir) | Önbelleğe alınan model listesini güncellemek için `--refresh` bayrağını kullanın. Bu, bir sağlayıcıya yeni modeller eklendiğinde ve bunları opencode'da görmek istediğinizde kullanışlıdır. @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Bayraklar -| Bayrak | Kısa | Açıklama | -| ------------ | ---- | --------------------------------------------------------------------------------- | -| `--command` | | Çalıştırılacak komut, args için mesajı kullanın | -| `--continue` | `-c` | Son oturuma devam et | -| `--session` | `-s` | Devam edecek oturum kimliği | -| `--fork` | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | -| `--share` | | Oturumu paylaşın | -| `--model` | `-m` | provider/model biçiminde kullanılacak model | -| `--agent` | | Kullanılacak temsilci | -| `--file` | `-f` | Mesaja eklenecek dosya(lar) | -| `--format` | | Biçim: varsayılan (biçimlendirilmiş) veya json (ham JSON olayları) | -| `--title` | | Oturumun başlığı (değer sağlanmazsa kısaltılmış bilgi istemi kullanılır) | -| `--attach` | | Çalışan bir opencode sunucusuna ekleyin (ör. http://localhost:4096) | -| `--port` | | Yerel sunucunun bağlantı noktası (varsayılan olarak rastgele bağlantı noktasıdır) | +| Bayrak | Kısa | Açıklama | +| ---------------------------------------- | ---- | --------------------------------------------------------------------------------------------- | +| {"--command"} | | Çalıştırılacak komut, args için mesajı kullanın | +| {"--continue"} | `-c` | Son oturuma devam et | +| {"--session"} | `-s` | Devam edecek oturum kimliği | +| {"--fork"} | | Devam ederken oturumu fork'lar (`--continue` veya `--session` ile kullanın) | +| {"--share"} | | Oturumu paylaşın | +| {"--model"} | `-m` | provider/model biçiminde kullanılacak model | +| {"--agent"} | | Kullanılacak temsilci | +| {"--file"} | `-f` | Mesaja eklenecek dosya(lar) | +| {"--format"} | | Biçim: varsayılan (biçimlendirilmiş) veya json (ham JSON olayları) | +| {"--title"} | | Oturumun başlığı (değer sağlanmazsa kısaltılmış bilgi istemi kullanılır) | +| {"--attach"} | | Çalışan bir opencode sunucusuna ekleyin (ör. http://localhost:4096) | +| {"--password"} | `-p` | Temel kimlik doğrulama parolası (varsayılan: `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Temel kimlik doğrulama kullanıcı adı (varsayılan: `OPENCODE_SERVER_USERNAME` veya `opencode`) | +| {"--dir"} | | Çalıştırılacak dizin veya bağlanırken uzak sunucudaki yol | +| {"--variant"} | | Model varyantı (sağlayıcıya özgü muhakeme düzeyi) | +| {"--thinking"} | | Düşünme bloklarını göster | +| {"--port"} | | Yerel sunucunun bağlantı noktası (varsayılan olarak rastgele bağlantı noktasıdır) | --- @@ -364,12 +373,12 @@ Bu, TUI arayüzü olmadan opencode işlevselliğine API erişimi sağlayan bir H #### Bayraklar -| Bayrak | Tanım | -| ------------ | ------------------------------------------ | -| `--port` | Dinlenecek bağlantı noktası | -| `--hostname` | Dinlenecek ana bilgisayar adı | -| `--mdns` | mDNS bulmayı etkinleştir | -| `--cors` | CORS'a izin verecek ek tarayıcı kaynakları | +| Bayrak | Tanım | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Dinlenecek bağlantı noktası | +| {"--hostname"} | Dinlenecek ana bilgisayar adı | +| {"--mdns"} | mDNS bulmayı etkinleştir | +| {"--cors"} | CORS'a izin verecek ek tarayıcı kaynakları | --- @@ -393,10 +402,10 @@ opencode session list ##### Bayraklar -| Bayrak | Kısa | Tanım | -| ------------- | ---- | -------------------------------------- | -| `--max-count` | `-n` | En son N oturumla sınırla | -| `--format` | | Çıkış formatı: tablo veya json (tablo) | +| Bayrak | Kısa | Tanım | +| ----------------------------------------- | ---- | -------------------------------------- | +| {"--max-count"} | `-n` | En son N oturumla sınırla | +| {"--format"} | | Çıkış formatı: tablo veya json (tablo) | --- @@ -410,12 +419,12 @@ opencode stats #### Bayraklar -| Bayrak | Açıklama | -| ----------- | ----------------------------------------------------------------------------------------------------------- | -| `--days` | Son N güne ait istatistikleri göster (tüm zamanlar) | -| `--tools` | Gösterilecek araç sayısı (tümü) | -| `--models` | Model kullanım dökümünü göster (varsayılan olarak gizlidir). En üstteki N'yi göstermek için bir sayı iletin | -| `--project` | Projeye göre filtrele (tüm projeler, boş değer: mevcut proje) | +| Bayrak | Açıklama | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| {"--days"} | Son N güne ait istatistikleri göster (tüm zamanlar) | +| {"--tools"} | Gösterilecek araç sayısı (tümü) | +| {"--models"} | Model kullanım dökümünü göster (varsayılan olarak gizlidir). En üstteki N'yi göstermek için bir sayı iletin | +| {"--project"} | Projeye göre filtrele (tüm projeler, boş değer: mevcut proje) | --- @@ -460,12 +469,12 @@ Bu, bir HTTP sunucusunu başlatır ve bir web arayüzü aracılığıyla opencod #### Bayraklar -| Bayrak | Tanım | -| ------------ | ------------------------------------------ | -| `--port` | Dinlenecek bağlantı noktası | -| `--hostname` | Dinlenecek ana bilgisayar adı | -| `--mdns` | mDNS bulmayı etkinleştir | -| `--cors` | CORS'a izin verecek ek tarayıcı kaynakları | +| Bayrak | Tanım | +| ---------------------------------------- | ------------------------------------------ | +| {"--port"} | Dinlenecek bağlantı noktası | +| {"--hostname"} | Dinlenecek ana bilgisayar adı | +| {"--mdns"} | mDNS bulmayı etkinleştir | +| {"--cors"} | CORS'a izin verecek ek tarayıcı kaynakları | --- @@ -481,11 +490,11 @@ Bu komut, nd-JSON kullanarak stdin/stdout aracılığıyla iletişim kuran bir A #### Bayraklar -| Bayrak | Açıklama | -| ------------ | ------------------- | -| `--cwd` | Çalışma dizini | -| `--port` | Dinlenecek port | -| `--hostname` | Dinlenecek host adı | +| Bayrak | Açıklama | +| ---------------------------------------- | ------------------- | +| {"--cwd"} | Çalışma dizini | +| {"--port"} | Dinlenecek port | +| {"--hostname"} | Dinlenecek host adı | --- @@ -499,12 +508,12 @@ opencode uninstall #### Bayraklar -| Bayrak | Kısa | Tanım | -| --------------- | ---- | ----------------------------------------------- | -| `--keep-config` | `-c` | Yapılandırma dosyalarını sakla | -| `--keep-data` | `-d` | Oturum verilerini ve anlık görüntüleri saklayın | -| `--dry-run` | | Nelerin kaldırılmadan kaldırılacağı göster | -| `--force` | `-f` | Onay istemlerini atla | +| Bayrak | Kısa | Tanım | +| ------------------------------------------- | ---- | ----------------------------------------------- | +| {"--keep-config"} | `-c` | Yapılandırma dosyalarını sakla | +| {"--keep-data"} | `-d` | Oturum verilerini ve anlık görüntüleri saklayın | +| {"--dry-run"} | | Nelerin kaldırılmadan kaldırılacağı göster | +| {"--force"} | `-f` | Onay istemlerini atla | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### Bayraklar -| Bayrak | Kısa | Açıklama | -| ---------- | ---- | ------------------------------------------------------ | -| `--method` | `-m` | Kullanılan kurulum yöntemi: curl, npm, pnpm, bun, brew | +| Bayrak | Kısa | Açıklama | +| -------------------------------------- | ---- | ------------------------------------------------------ | +| {"--method"} | `-m` | Kullanılan kurulum yöntemi: curl, npm, pnpm, bun, brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 opencode CLI aşağıdaki global bayrakları destekler. -| Bayrak | Kısa | Tanım | -| -------------- | ---- | ---------------------------------------- | -| `--help` | `-h` | Yardımı görüntüle | -| `--version` | `-v` | Sürüm numarasını yazdır | -| `--print-logs` | | Günlükleri stderr'e yazdır | -| `--log-level` | | Günlük düzeyi (DEBUG, INFO, WARN, ERROR) | +| Bayrak | Kısa | Tanım | +| ------------------------------------------ | ---- | ---------------------------------------- | +| {"--help"} | `-h` | Yardımı görüntüle | +| {"--version"} | `-v` | Sürüm numarasını yazdır | +| {"--print-logs"} | | Günlükleri stderr'e yazdır | +| {"--log-level"} | | Günlük düzeyi (DEBUG, INFO, WARN, ERROR) | --- diff --git a/packages/web/src/content/docs/zh-cn/cli.mdx b/packages/web/src/content/docs/zh-cn/cli.mdx index c0cff134a5..aa992b6734 100644 --- a/packages/web/src/content/docs/zh-cn/cli.mdx +++ b/packages/web/src/content/docs/zh-cn/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### 标志 -| 标志 | 简写 | 描述 | -| ------------ | ---- | --------------------------------------------------------- | -| `--continue` | `-c` | 继续上一个会话 | -| `--session` | `-s` | 要继续的会话 ID | -| `--fork` | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | -| `--prompt` | | 要使用的提示词 | -| `--model` | `-m` | 要使用的模型,格式为 provider/model | -| `--agent` | | 要使用的代理 | -| `--port` | | 监听端口 | -| `--hostname` | | 监听主机名 | +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | --------------------------------------------------------- | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | +| {"--prompt"} | | 要使用的提示词 | +| {"--model"} | `-m` | 要使用的模型,格式为 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--port"} | | 监听端口 | +| {"--hostname"} | | 监听主机名 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### 标志 -| 标志 | 简写 | 描述 | -| ----------- | ---- | ------------------- | -| `--dir` | | 启动 TUI 的工作目录 | -| `--session` | `-s` | 要继续的会话 ID | +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | ------------------------------------------------------------------- | +| {"--dir"} | | 启动 TUI 的工作目录 | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时派生会话(与 `--continue` 或 `--session` 一起使用) | +| {"--password"} | `-p` | 基本认证密码(默认使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本认证用户名(默认使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### 标志 -| 标志 | 描述 | -| --------- | ------------------------------ | -| `--event` | 用于运行代理的 GitHub 模拟事件 | -| `--token` | GitHub 个人访问令牌 | +| 标志 | 描述 | +| ------------------------------------- | ------------------------------ | +| {"--event"} | 用于运行代理的 GitHub 模拟事件 | +| {"--token"} | GitHub 个人访问令牌 | --- @@ -296,10 +300,10 @@ opencode models anthropic #### 标志 -| 标志 | 描述 | -| ----------- | ---------------------------------------- | -| `--refresh` | 从 models.dev 刷新模型缓存 | -| `--verbose` | 使用更详细的模型输出(包含费用等元数据) | +| 标志 | 描述 | +| --------------------------------------- | ---------------------------------------- | +| {"--refresh"} | 从 models.dev 刷新模型缓存 | +| {"--verbose"} | 使用更详细的模型输出(包含费用等元数据) | 使用 `--refresh` 标志可以更新缓存的模型列表。当提供商新增了模型并且您希望在 OpenCode 中看到它们时,此功能非常有用。 @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### 标志 -| 标志 | 简写 | 描述 | -| ------------ | ---- | -------------------------------------------------------------- | -| `--command` | | 要运行的命令,使用 message 作为参数 | -| `--continue` | `-c` | 继续上一个会话 | -| `--session` | `-s` | 要继续的会话 ID | -| `--fork` | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | -| `--share` | | 分享会话 | -| `--model` | `-m` | 要使用的模型,格式为 provider/model | -| `--agent` | | 要使用的代理 | -| `--file` | `-f` | 附加到消息的文件 | -| `--format` | | 格式:default(格式化输出)或 json(原始 JSON 事件) | -| `--title` | | 会话标题(未提供值时使用截断的提示词) | -| `--attach` | | 连接到正在运行的 opencode 服务器(例如 http://localhost:4096) | -| `--port` | | 本地服务器端口(默认为随机端口) | +| 标志 | 简写 | 描述 | +| ---------------------------------------- | ---- | ------------------------------------------------------------------- | +| {"--command"} | | 要运行的命令,使用 message 作为参数 | +| {"--continue"} | `-c` | 继续上一个会话 | +| {"--session"} | `-s` | 要继续的会话 ID | +| {"--fork"} | | 继续时分叉会话(与 `--continue` 或 `--session` 配合使用) | +| {"--share"} | | 分享会话 | +| {"--model"} | `-m` | 要使用的模型,格式为 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--file"} | `-f` | 附加到消息的文件 | +| {"--format"} | | 格式:default(格式化输出)或 json(原始 JSON 事件) | +| {"--title"} | | 会话标题(未提供值时使用截断的提示词) | +| {"--attach"} | | 连接到正在运行的 opencode 服务器(例如 http://localhost:4096) | +| {"--password"} | `-p` | 基本认证密码(默认使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本认证用户名(默认使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | +| {"--dir"} | | 运行目录,或附加时远程服务器上的路径 | +| {"--variant"} | | 模型变体(特定于提供商的推理级别) | +| {"--thinking"} | | 显示思考块 | +| {"--port"} | | 本地服务器端口(默认为随机端口) | --- @@ -364,12 +373,12 @@ opencode serve #### 标志 -| 标志 | 描述 | -| ------------ | -------------------------- | -| `--port` | 监听端口 | -| `--hostname` | 监听主机名 | -| `--mdns` | 启用 mDNS 发现 | -| `--cors` | 允许 CORS 的额外浏览器来源 | +| 标志 | 描述 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | +| {"--mdns"} | 启用 mDNS 发现 | +| {"--cors"} | 允许 CORS 的额外浏览器来源 | --- @@ -393,10 +402,10 @@ opencode session list ##### 标志 -| 标志 | 简写 | 描述 | -| ------------- | ---- | ------------------------------------- | -| `--max-count` | `-n` | 限制为最近 N 个会话 | -| `--format` | | 输出格式:table 或 json(默认 table) | +| 标志 | 简写 | 描述 | +| ----------------------------------------- | ---- | ------------------------------------- | +| {"--max-count"} | `-n` | 限制为最近 N 个会话 | +| {"--format"} | | 输出格式:table 或 json(默认 table) | --- @@ -410,12 +419,12 @@ opencode stats #### 标志 -| 标志 | 描述 | -| ----------- | ------------------------------------------------------ | -| `--days` | 显示最近 N 天的统计信息(默认为所有时间) | -| `--tools` | 显示的工具数量(默认为全部) | -| `--models` | 显示模型用量明细(默认隐藏)。传入数字可显示前 N 个 | -| `--project` | 按项目筛选(默认为所有项目,传入空字符串表示当前项目) | +| 标志 | 描述 | +| --------------------------------------- | ------------------------------------------------------ | +| {"--days"} | 显示最近 N 天的统计信息(默认为所有时间) | +| {"--tools"} | 显示的工具数量(默认为全部) | +| {"--models"} | 显示模型用量明细(默认隐藏)。传入数字可显示前 N 个 | +| {"--project"} | 按项目筛选(默认为所有项目,传入空字符串表示当前项目) | --- @@ -460,12 +469,12 @@ opencode web #### 标志 -| 标志 | 描述 | -| ------------ | -------------------------- | -| `--port` | 监听端口 | -| `--hostname` | 监听主机名 | -| `--mdns` | 启用 mDNS 发现 | -| `--cors` | 允许 CORS 的额外浏览器来源 | +| 标志 | 描述 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | +| {"--mdns"} | 启用 mDNS 发现 | +| {"--cors"} | 允许 CORS 的额外浏览器来源 | --- @@ -481,11 +490,11 @@ opencode acp #### 标志 -| 标志 | 描述 | -| ------------ | ---------- | -| `--cwd` | 工作目录 | -| `--port` | 监听端口 | -| `--hostname` | 监听主机名 | +| 标志 | 描述 | +| ---------------------------------------- | ---------- | +| {"--cwd"} | 工作目录 | +| {"--port"} | 监听端口 | +| {"--hostname"} | 监听主机名 | --- @@ -499,12 +508,12 @@ opencode uninstall #### 标志 -| 标志 | 简写 | 描述 | -| --------------- | ---- | ------------------------------ | -| `--keep-config` | `-c` | 保留配置文件 | -| `--keep-data` | `-d` | 保留会话数据和快照 | -| `--dry-run` | | 显示将被删除的内容但不实际删除 | -| `--force` | `-f` | 跳过确认提示 | +| 标志 | 简写 | 描述 | +| ------------------------------------------- | ---- | ------------------------------ | +| {"--keep-config"} | `-c` | 保留配置文件 | +| {"--keep-data"} | `-d` | 保留会话数据和快照 | +| {"--dry-run"} | | 显示将被删除的内容但不实际删除 | +| {"--force"} | `-f` | 跳过确认提示 | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### 标志 -| 标志 | 简写 | 描述 | -| ---------- | ---- | ------------------------------------------ | -| `--method` | `-m` | 使用的安装方式:curl、npm、pnpm、bun、brew | +| 标志 | 简写 | 描述 | +| -------------------------------------- | ---- | ------------------------------------------ | +| {"--method"} | `-m` | 使用的安装方式:curl、npm、pnpm、bun、brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 OpenCode CLI 接受以下全局标志。 -| 标志 | 简写 | 描述 | -| -------------- | ---- | ------------------------------------ | -| `--help` | `-h` | 显示帮助信息 | -| `--version` | `-v` | 打印版本号 | -| `--print-logs` | | 将日志输出到 stderr | -| `--log-level` | | 日志级别(DEBUG、INFO、WARN、ERROR) | +| 标志 | 简写 | 描述 | +| ------------------------------------------ | ---- | ------------------------------------ | +| {"--help"} | `-h` | 显示帮助信息 | +| {"--version"} | `-v` | 打印版本号 | +| {"--print-logs"} | | 将日志输出到 stderr | +| {"--log-level"} | | 日志级别(DEBUG、INFO、WARN、ERROR) | --- diff --git a/packages/web/src/content/docs/zh-tw/cli.mdx b/packages/web/src/content/docs/zh-tw/cli.mdx index 4df9d13fdd..0d51bff2f8 100644 --- a/packages/web/src/content/docs/zh-tw/cli.mdx +++ b/packages/web/src/content/docs/zh-tw/cli.mdx @@ -29,16 +29,16 @@ opencode [project] #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ------------ | ---- | ------------------------------------------------------------- | -| `--continue` | `-c` | 繼續上一個工作階段 | -| `--session` | `-s` | 要繼續的工作階段 ID | -| `--fork` | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | -| `--prompt` | | 要使用的提示詞 | -| `--model` | `-m` | 要使用的模型,格式為 provider/model | -| `--agent` | | 要使用的代理 | -| `--port` | | 監聽連接埠 | -| `--hostname` | | 監聽主機名稱 | +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ------------------------------------------------------------- | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | +| {"--prompt"} | | 要使用的提示詞 | +| {"--model"} | `-m` | 要使用的模型,格式為 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--port"} | | 監聽連接埠 | +| {"--hostname"} | | 監聽主機名稱 | --- @@ -78,10 +78,14 @@ opencode attach http://10.20.30.40:4096 #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ----------- | ---- | ------------------- | -| `--dir` | | 啟動 TUI 的工作目錄 | -| `--session` | `-s` | 要繼續的工作階段 ID | +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ----------------------------------------------------------------------- | +| {"--dir"} | | 啟動 TUI 的工作目錄 | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分支工作階段(與 `--continue` 或 `--session` 一起使用) | +| {"--password"} | `-p` | 基本驗證密碼(預設使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本驗證使用者名稱(預設使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | --- @@ -187,10 +191,10 @@ opencode github run ##### 旗標 -| 旗標 | 說明 | -| --------- | ------------------------------ | -| `--event` | 用於執行代理的 GitHub 模擬事件 | -| `--token` | GitHub 個人存取權杖 | +| 旗標 | 說明 | +| ------------------------------------- | ------------------------------ | +| {"--event"} | 用於執行代理的 GitHub 模擬事件 | +| {"--token"} | GitHub 個人存取權杖 | --- @@ -296,10 +300,10 @@ opencode models anthropic #### 旗標 -| 旗標 | 說明 | -| ----------- | ------------------------------------------ | -| `--refresh` | 從 models.dev 重新整理模型快取 | -| `--verbose` | 使用更詳細的模型輸出(包含費用等中繼資料) | +| 旗標 | 說明 | +| --------------------------------------- | ------------------------------------------ | +| {"--refresh"} | 從 models.dev 重新整理模型快取 | +| {"--verbose"} | 使用更詳細的模型輸出(包含費用等中繼資料) | 使用 `--refresh` 旗標可以更新快取的模型列表。當供應商新增了模型並且您希望在 OpenCode 中看到它們時,此功能非常有用。 @@ -335,20 +339,25 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ------------ | ---- | -------------------------------------------------------------- | -| `--command` | | 要執行的指令,使用 message 作為參數 | -| `--continue` | `-c` | 繼續上一個工作階段 | -| `--session` | `-s` | 要繼續的工作階段 ID | -| `--fork` | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | -| `--share` | | 分享工作階段 | -| `--model` | `-m` | 要使用的模型,格式為 provider/model | -| `--agent` | | 要使用的代理 | -| `--file` | `-f` | 附加到訊息的檔案 | -| `--format` | | 格式:default(格式化輸出)或 json(原始 JSON 事件) | -| `--title` | | 工作階段標題(未提供值時使用截斷的提示詞) | -| `--attach` | | 連接到正在執行的 opencode 伺服器(例如 http://localhost:4096) | -| `--port` | | 本地伺服器連接埠(預設為隨機連接埠) | +| 旗標 | 簡寫 | 說明 | +| ---------------------------------------- | ---- | ----------------------------------------------------------------------- | +| {"--command"} | | 要執行的指令,使用 message 作為參數 | +| {"--continue"} | `-c` | 繼續上一個工作階段 | +| {"--session"} | `-s` | 要繼續的工作階段 ID | +| {"--fork"} | | 繼續時分岔工作階段(與 `--continue` 或 `--session` 搭配使用) | +| {"--share"} | | 分享工作階段 | +| {"--model"} | `-m` | 要使用的模型,格式為 provider/model | +| {"--agent"} | | 要使用的代理 | +| {"--file"} | `-f` | 附加到訊息的檔案 | +| {"--format"} | | 格式:default(格式化輸出)或 json(原始 JSON 事件) | +| {"--title"} | | 工作階段標題(未提供值時使用截斷的提示詞) | +| {"--attach"} | | 連接到正在執行的 opencode 伺服器(例如 http://localhost:4096) | +| {"--password"} | `-p` | 基本驗證密碼(預設使用 `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | 基本驗證使用者名稱(預設使用 `OPENCODE_SERVER_USERNAME` 或 `opencode`) | +| {"--dir"} | | 執行目錄,或附加時遠端伺服器上的路徑 | +| {"--variant"} | | 模型變體(特定於提供者的推理級別) | +| {"--thinking"} | | 顯示思考區塊 | +| {"--port"} | | 本地伺服器連接埠(預設為隨機連接埠) | --- @@ -364,12 +373,12 @@ opencode serve #### 旗標 -| 旗標 | 說明 | -| ------------ | -------------------------- | -| `--port` | 監聽連接埠 | -| `--hostname` | 監聽主機名稱 | -| `--mdns` | 啟用 mDNS 探索 | -| `--cors` | 允許 CORS 的額外瀏覽器來源 | +| 旗標 | 說明 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | +| {"--mdns"} | 啟用 mDNS 探索 | +| {"--cors"} | 允許 CORS 的額外瀏覽器來源 | --- @@ -393,10 +402,10 @@ opencode session list ##### 旗標 -| 旗標 | 簡寫 | 說明 | -| ------------- | ---- | ------------------------------------- | -| `--max-count` | `-n` | 限制為最近 N 個工作階段 | -| `--format` | | 輸出格式:table 或 json(預設 table) | +| 旗標 | 簡寫 | 說明 | +| ----------------------------------------- | ---- | ------------------------------------- | +| {"--max-count"} | `-n` | 限制為最近 N 個工作階段 | +| {"--format"} | | 輸出格式:table 或 json(預設 table) | --- @@ -410,12 +419,12 @@ opencode stats #### 旗標 -| 旗標 | 說明 | -| ----------- | ---------------------------------------------------- | -| `--days` | 顯示最近 N 天的統計資訊(預設為所有時間) | -| `--tools` | 顯示的工具數量(預設為全部) | -| `--models` | 顯示模型用量明細(預設隱藏)。傳入數字可顯示前 N 個 | -| `--project` | 按專案篩選(預設為所有專案,傳入空字串表示當前專案) | +| 旗標 | 說明 | +| --------------------------------------- | ---------------------------------------------------- | +| {"--days"} | 顯示最近 N 天的統計資訊(預設為所有時間) | +| {"--tools"} | 顯示的工具數量(預設為全部) | +| {"--models"} | 顯示模型用量明細(預設隱藏)。傳入數字可顯示前 N 個 | +| {"--project"} | 按專案篩選(預設為所有專案,傳入空字串表示當前專案) | --- @@ -460,12 +469,12 @@ opencode web #### 旗標 -| 旗標 | 說明 | -| ------------ | -------------------------- | -| `--port` | 監聽連接埠 | -| `--hostname` | 監聽主機名稱 | -| `--mdns` | 啟用 mDNS 探索 | -| `--cors` | 允許 CORS 的額外瀏覽器來源 | +| 旗標 | 說明 | +| ---------------------------------------- | -------------------------- | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | +| {"--mdns"} | 啟用 mDNS 探索 | +| {"--cors"} | 允許 CORS 的額外瀏覽器來源 | --- @@ -481,11 +490,11 @@ opencode acp #### 旗標 -| 旗標 | 說明 | -| ------------ | ------------ | -| `--cwd` | 工作目錄 | -| `--port` | 監聽連接埠 | -| `--hostname` | 監聽主機名稱 | +| 旗標 | 說明 | +| ---------------------------------------- | ------------ | +| {"--cwd"} | 工作目錄 | +| {"--port"} | 監聽連接埠 | +| {"--hostname"} | 監聽主機名稱 | --- @@ -499,12 +508,12 @@ opencode uninstall #### 旗標 -| 旗標 | 簡寫 | 說明 | -| --------------- | ---- | ------------------------------ | -| `--keep-config` | `-c` | 保留設定檔 | -| `--keep-data` | `-d` | 保留工作階段資料和快照 | -| `--dry-run` | | 顯示將被刪除的內容但不實際刪除 | -| `--force` | `-f` | 跳過確認提示 | +| 旗標 | 簡寫 | 說明 | +| ------------------------------------------- | ---- | ------------------------------ | +| {"--keep-config"} | `-c` | 保留設定檔 | +| {"--keep-data"} | `-d` | 保留工作階段資料和快照 | +| {"--dry-run"} | | 顯示將被刪除的內容但不實際刪除 | +| {"--force"} | `-f` | 跳過確認提示 | --- @@ -530,9 +539,9 @@ opencode upgrade v0.1.48 #### 旗標 -| 旗標 | 簡寫 | 說明 | -| ---------- | ---- | ------------------------------------------ | -| `--method` | `-m` | 使用的安裝方式:curl、npm、pnpm、bun、brew | +| 旗標 | 簡寫 | 說明 | +| -------------------------------------- | ---- | ------------------------------------------ | +| {"--method"} | `-m` | 使用的安裝方式:curl、npm、pnpm、bun、brew | --- @@ -540,12 +549,12 @@ opencode upgrade v0.1.48 OpenCode CLI 接受以下全域旗標。 -| 旗標 | 簡寫 | 說明 | -| -------------- | ---- | ------------------------------------ | -| `--help` | `-h` | 顯示說明資訊 | -| `--version` | `-v` | 印出版本號 | -| `--print-logs` | | 將日誌輸出到 stderr | -| `--log-level` | | 日誌等級(DEBUG、INFO、WARN、ERROR) | +| 旗標 | 簡寫 | 說明 | +| ------------------------------------------ | ---- | ------------------------------------ | +| {"--help"} | `-h` | 顯示說明資訊 | +| {"--version"} | `-v` | 印出版本號 | +| {"--print-logs"} | | 將日誌輸出到 stderr | +| {"--log-level"} | | 日誌等級(DEBUG、INFO、WARN、ERROR) | --- From 146ff8ad855072b8ea5b8e92a91a5ffe53f2ed78 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 12:08:04 -0400 Subject: [PATCH 033/178] feat(cli): add effectCmd wrapper + convert models command (#25429) --- packages/opencode/src/cli/cmd/models.ts | 99 ++++++++------------- packages/opencode/src/cli/effect-cmd.ts | 52 +++++++++++ packages/opencode/src/cli/error.ts | 7 ++ packages/opencode/src/effect/app-runtime.ts | 3 + 4 files changed, 101 insertions(+), 60 deletions(-) create mode 100644 packages/opencode/src/cli/effect-cmd.ts diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 0b5d352755..cfbb959e7a 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -1,19 +1,16 @@ -import type { Argv } from "yargs" -import { Instance } from "../../project/instance" +import { EOL } from "os" +import { Effect } from "effect" import { Provider } from "@/provider/provider" import { ProviderID } from "../../provider/schema" import { ModelsDev } from "@/provider/models" -import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" -import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" -import { Effect } from "effect" -export const ModelsCommand = cmd({ +export const ModelsCommand = effectCmd({ command: "models [provider]", describe: "list all available models", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("provider", { describe: "provider ID to filter models by", type: "string", @@ -26,63 +23,45 @@ export const ModelsCommand = cmd({ .option("refresh", { describe: "refresh the models cache from models.dev", type: "boolean", - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.models")(function* (args) { if (args.refresh) { - await ModelsDev.refresh(true) + // followup: lift ModelsDev into an Effect Service so this drops the Effect.promise wrap. + yield* Effect.promise(() => ModelsDev.refresh(true)) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) } - await Instance.provide({ - directory: process.cwd(), - async fn() { - await AppRuntime.runPromise( - Effect.gen(function* () { - const svc = yield* Provider.Service - const providers = yield* svc.list() + const provider = yield* Provider.Service + const providers = yield* provider.list() - const print = (providerID: ProviderID, verbose?: boolean) => { - const provider = providers[providerID] - const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) - for (const [modelID, model] of sorted) { - process.stdout.write(`${providerID}/${modelID}`) - process.stdout.write(EOL) - if (verbose) { - process.stdout.write(JSON.stringify(model, null, 2)) - process.stdout.write(EOL) - } - } - } - - if (args.provider) { - const providerID = ProviderID.make(args.provider) - const provider = providers[providerID] - if (!provider) { - yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`)) - return - } - - yield* Effect.sync(() => print(providerID, args.verbose)) - return - } + const print = (providerID: ProviderID, verbose?: boolean) => { + const p = providers[providerID] + const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b)) + for (const [modelID, model] of sorted) { + process.stdout.write(`${providerID}/${modelID}`) + process.stdout.write(EOL) + if (verbose) { + process.stdout.write(JSON.stringify(model, null, 2)) + process.stdout.write(EOL) + } + } + } - const ids = Object.keys(providers).sort((a, b) => { - const aIsOpencode = a.startsWith("opencode") - const bIsOpencode = b.startsWith("opencode") - if (aIsOpencode && !bIsOpencode) return -1 - if (!aIsOpencode && bIsOpencode) return 1 - return a.localeCompare(b) - }) + if (args.provider) { + const providerID = ProviderID.make(args.provider) + if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`) + print(providerID, args.verbose) + return + } - yield* Effect.sync(() => { - for (const providerID of ids) { - print(ProviderID.make(providerID), args.verbose) - } - }) - }), - ) - }, + const ids = Object.keys(providers).sort((a, b) => { + const aIsOpencode = a.startsWith("opencode") + const bIsOpencode = b.startsWith("opencode") + if (aIsOpencode && !bIsOpencode) return -1 + if (!aIsOpencode && bIsOpencode) return 1 + return a.localeCompare(b) }) - }, + + for (const providerID of ids) print(ProviderID.make(providerID), args.verbose) + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts new file mode 100644 index 0000000000..758ac6904a --- /dev/null +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -0,0 +1,52 @@ +import type { Argv } from "yargs" +import { Effect, Schema } from "effect" +import { AppRuntime, type AppServices } from "@/effect/app-runtime" +import { InstanceStore } from "@/project/instance-store" +import { cmd } from "./cmd/cmd" + +/** + * User-visible command failure. Throw via `fail("...")` from an effectCmd handler + * to surface a printed message + non-zero exit. Recognised by the global error + * formatter in `src/cli/error.ts` (FormatError), so the existing top-level + * catch + cleanup in `src/index.ts` runs normally. + */ +export class CliError extends Schema.TaggedErrorClass()("CliError", { + message: Schema.String, + exitCode: Schema.optional(Schema.Number), +}) {} + +export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) + +/** + * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is + * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. + * + * Errors propagate to the existing top-level handler in `src/index.ts`; use + * `fail("...")` for user-visible domain failures (clean exit, formatted message). + * + * Handlers are typically `Effect.fn("Cli.")(function*(args) { ... })`, + * which adds a named tracing span per CLI invocation. Once all commands use + * `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's + * `Command.make(...)` won't touch any handler bodies. + */ +export const effectCmd = (opts: { + command: string | readonly string[] + describe: string | false + builder?: (yargs: Argv) => Argv + /** Defaults to process.cwd(). Override for commands that take a directory positional. */ + directory?: (args: Args) => string + handler: (args: Args) => Effect.Effect +}) => + cmd<{}, Args>({ + command: opts.command, + describe: opts.describe, + builder: opts.builder as never, + async handler(rawArgs) { + // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. + const args = rawArgs as unknown as Args + const directory = opts.directory?.(args) ?? process.cwd() + await AppRuntime.runPromise( + InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args))), + ) + }, + }) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index adf52f5683..628aa95696 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -15,6 +15,13 @@ function isTaggedError(error: unknown, tag: string): boolean { } export function FormatError(input: unknown) { + // CliError: domain failure surfaced from an effectCmd handler via fail("...") + if (isTaggedError(input, "CliError")) { + const data = input as ErrorLike & { exitCode?: number } + if (data.exitCode != null) process.exitCode = data.exitCode + return data.message ?? "" + } + // MCPFailed: { name: string } if (NamedError.hasName(input, "MCPFailed")) { return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.` diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index f3376ad859..97cd2f629e 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -105,6 +105,9 @@ export const AppLayer = Layer.mergeAll( const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick + +/** Services provided by AppRuntime — i.e. what an Effect run via AppRuntime.runPromise can yield. */ +export type AppServices = ManagedRuntime.ManagedRuntime.Services const wrap = (effect: Parameters[0]) => attach(effect as never) as never export const AppRuntime: Runtime = { From ff4779ca11a0a2daa7541dcaa054d5c7c4a88d8b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 16:09:04 +0000 Subject: [PATCH 034/178] chore: generate --- packages/opencode/src/cli/effect-cmd.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index 758ac6904a..cc4dd2ed7e 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -45,8 +45,6 @@ export const effectCmd = (opts: { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. const args = rawArgs as unknown as Args const directory = opts.directory?.(args) ?? process.cwd() - await AppRuntime.runPromise( - InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args))), - ) + await AppRuntime.runPromise(InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args)))) }, }) From b460db15d7cb8613e7619f429f9b660506954639 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 2 May 2026 11:12:07 -0500 Subject: [PATCH 035/178] tweak: allow read tool to accept offset of 0 (#25431) --- packages/opencode/src/tool/read.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 78436489f5..bf01fc7d2d 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -154,10 +154,6 @@ export const ReadTool = Tool.define( params: Schema.Schema.Type, ctx: Tool.Context, ) { - if (params.offset !== undefined && params.offset < 1) { - return yield* Effect.fail(new Error("offset must be greater than or equal to 1")) - } - const instance = yield* InstanceState.context let filepath = params.filePath if (!path.isAbsolute(filepath)) { @@ -192,7 +188,7 @@ export const ReadTool = Tool.define( if (stat.type === "Directory") { const items = yield* list(filepath) const limit = params.limit ?? DEFAULT_READ_LIMIT - const offset = params.offset ?? 1 + const offset = params.offset || 1 const start = offset - 1 const sliced = items.slice(start, start + limit) const truncated = start + sliced.length < items.length @@ -249,7 +245,7 @@ export const ReadTool = Tool.define( } const file = yield* Effect.promise(() => - lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset ?? 1 }), + lines(filepath, { limit: params.limit ?? DEFAULT_READ_LIMIT, offset: params.offset || 1 }), ) if (file.count < file.offset && !(file.count === 0 && file.offset === 1)) { return yield* Effect.fail( From f8738c900285d6725ca79ca7b47c8c5ccee1a56e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 13:59:08 -0400 Subject: [PATCH 036/178] feat(models): effectify ModelsDev as Service (#25434) --- packages/opencode/src/cli/cmd/models.ts | 3 +- packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/provider/models.ts | 188 +++++++------ packages/opencode/src/provider/provider.ts | 6 +- .../instance/httpapi/handlers/provider.ts | 2 +- .../server/routes/instance/httpapi/server.ts | 2 + .../src/server/routes/instance/provider.ts | 2 +- .../opencode/test/provider/models.test.ts | 260 ++++++++++++++++++ 8 files changed, 381 insertions(+), 84 deletions(-) create mode 100644 packages/opencode/test/provider/models.test.ts diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index cfbb959e7a..183b1816d2 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -26,8 +26,7 @@ export const ModelsCommand = effectCmd({ }), handler: Effect.fn("Cli.models")(function* (args) { if (args.refresh) { - // followup: lift ModelsDev into an Effect Service so this drops the Effect.promise wrap. - yield* Effect.promise(() => ModelsDev.refresh(true)) + yield* ModelsDev.Service.use((s) => s.refresh(true)) UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL) } diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 97cd2f629e..bbf1f4f8de 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -14,6 +14,7 @@ import { FileWatcher } from "@/file/watcher" import { Storage } from "@/storage/storage" import { Snapshot } from "@/snapshot" import { Plugin } from "@/plugin" +import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { ProviderAuth } from "@/provider/auth" import { Agent } from "@/agent/agent" @@ -66,6 +67,7 @@ export const AppLayer = Layer.mergeAll( Storage.defaultLayer, Snapshot.defaultLayer, Plugin.defaultLayer, + ModelsDev.defaultLayer, Provider.defaultLayer, ProviderAuth.defaultLayer, Agent.defaultLayer, diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 170fe516c9..3654f66c79 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -1,25 +1,14 @@ import { Global } from "@opencode-ai/core/global" -import * as Log from "@opencode-ai/core/util/log" import path from "path" -import { Schema } from "effect" +import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { Installation } from "../installation" import { Flag } from "@opencode-ai/core/flag/flag" -import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" - -// Try to import bundled snapshot (generated at build time) -// Falls back to undefined in dev mode when snapshot doesn't exist -/* @ts-ignore */ - -const log = Log.create({ service: "models.dev" }) -const source = url() -const filepath = path.join( - Global.Path.cache, - source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, -) -const ttl = 5 * 60 * 1000 +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { makeRuntime } from "@/effect/run-service" +import { withTransientReadRetry } from "@/util/effect-http-client" const Cost = Schema.Struct({ input: Schema.Finite, @@ -101,76 +90,119 @@ export const Provider = Schema.Struct({ export type Provider = Schema.Schema.Type -function url() { - return Flag.OPENCODE_MODELS_URL || "https://models.dev" +export interface Interface { + readonly get: () => Effect.Effect> + readonly refresh: (force?: boolean) => Effect.Effect } -function fresh() { - return Date.now() - Number(Filesystem.stat(filepath)?.mtimeMs ?? 0) < ttl -} +export class Service extends Context.Service()("@opencode/ModelsDev") {} -function skip(force: boolean) { - return !force && fresh() -} +export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient)) -const fetchApi = async () => { - const result = await fetch(`${url()}/api.json`, { - headers: { "User-Agent": Installation.USER_AGENT }, - signal: AbortSignal.timeout(10000), - }) - return { ok: result.ok, text: await result.text() } -} + const source = Flag.OPENCODE_MODELS_URL || "https://models.dev" + const filepath = path.join( + Global.Path.cache, + source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, + ) + const ttl = Duration.minutes(5) + const lockKey = `models-dev:${filepath}` -export const Data = lazy(async () => { - const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) - if (result) return result - // @ts-ignore - const snapshot = await import("./models-snapshot.js") - .then((m) => m.snapshot as Record) - .catch(() => undefined) - if (snapshot) return snapshot - if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} - return Flock.withLock(`models-dev:${filepath}`, async () => { - const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) - if (result) return result - const result2 = await fetchApi() - if (result2.ok) { - await Filesystem.write(filepath, result2.text).catch((e) => { - log.error("Failed to write models cache", { error: e }) - }) - } - return JSON.parse(result2.text) - }) -}) + const fresh = Effect.fnUntraced(function* () { + const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!stat) return false + const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime() + return Date.now() - mtime < Duration.toMillis(ttl) + }) -export async function get() { - const result = await Data() - return result as Record -} + const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () { + return yield* HttpClientRequest.get(`${source}/api.json`).pipe( + HttpClientRequest.setHeader("User-Agent", Installation.USER_AGENT), + http.execute, + Effect.flatMap((res) => res.text), + Effect.timeout("10 seconds"), + ) + }) -export async function refresh(force = false) { - if (skip(force)) return Data.reset() - await Flock.withLock(`models-dev:${filepath}`, async () => { - if (skip(force)) return Data.reset() - const result = await fetchApi() - if (!result.ok) return - await Filesystem.write(filepath, result.text) - Data.reset() - }).catch((e) => { - log.error("Failed to fetch models.dev", { - error: e, + const loadFromDisk = fs + .readJson(Flag.OPENCODE_MODELS_PATH ?? filepath) + .pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.map((v) => v as Record | undefined), + ) + + // Bundled at build time; absent in dev — `tryPromise` covers both. + const loadSnapshot = Effect.tryPromise({ + // @ts-ignore — generated at build time, may not exist in dev + try: () => import("./models-snapshot.js").then((m) => m.snapshot as Record | undefined), + catch: () => undefined, + }).pipe(Effect.catch(() => Effect.succeed(undefined))) + + const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () { + const text = yield* fetchApi() + yield* fs.writeWithDirs(filepath, text) + return text }) - }) -} -if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { - void refresh() - setInterval( - async () => { - await refresh() - }, - 60 * 1000 * 60, - ).unref() -} + const populate = Effect.gen(function* () { + const fromDisk = yield* loadFromDisk + if (fromDisk) return fromDisk + const snapshot = yield* loadSnapshot + if (snapshot) return snapshot + if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {} + // Flock is cross-process: concurrent opencode CLIs can race on this cache file. + const text = yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + return yield* fetchAndWrite() + }), + ) + return JSON.parse(text) as Record + }).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie) + + const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity) + + const get = (): Effect.Effect> => cachedGet + + const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) { + if (!force && (yield* fresh())) return + yield* Effect.scoped( + Effect.gen(function* () { + yield* Flock.effect(lockKey) + // Re-check under the lock: another process may have refreshed between + // our outer check and lock acquisition. + if (!force && (yield* fresh())) return + yield* fetchAndWrite() + yield* invalidate + }), + ).pipe( + Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause })), + Effect.ignore, + ) + }) + + if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) { + // Schedule.spaced runs the effect once, then waits between completions. + yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore)) + } + + return Service.of({ get, refresh }) + }), +) + +export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(AppFileSystem.defaultLayer), +) + +// Promise-style compat for callers in Promise-context (Hono routes, legacy CLI handlers). +// makeRuntime uses the shared memoMap so this runtime's Service instance is the same one +// AppRuntime sees — Effect callers and Promise callers operate on the same cache. +const runtime = makeRuntime(Service, defaultLayer) +export const get = () => runtime.runPromise((s) => s.get()) +export const refresh = (force = false) => runtime.runPromise((s) => s.refresh(force)) export * as ModelsDev from "./models" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 7d9806d139..939110e044 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1074,7 +1074,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { const layer: Layer.Layer< Service, never, - Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service + Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service | ModelsDev.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -1083,13 +1083,14 @@ const layer: Layer.Layer< const auth = yield* Auth.Service const env = yield* Env.Service const plugin = yield* Plugin.Service + const modelsDevSvc = yield* ModelsDev.Service const state = yield* InstanceState.make(() => Effect.gen(function* () { using _ = log.time("state") const bridge = yield* EffectBridge.make() const cfg = yield* config.get() - const modelsDev = yield* Effect.promise(() => ModelsDev.get()) + const modelsDev = yield* modelsDevSvc.get() const database = mapValues(modelsDev, fromModelsDevProvider) const providers: Record = {} as Record @@ -1722,6 +1723,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(ModelsDev.defaultLayer), ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts index c8689eabab..f9df530a92 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts @@ -17,7 +17,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider" const list = Effect.fn("ProviderHttpApi.list")(function* () { const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) + const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 3ac0298c6b..767bfc31db 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -23,6 +23,7 @@ import { InstanceStore } from "@/project/instance-store" import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" +import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { Pty } from "@/pty" import { Question } from "@/question" @@ -155,6 +156,7 @@ export function createRoutes(corsOptions?: CorsOptions) { InstanceBootstrap.defaultLayer, InstanceStore.defaultLayer, MCP.defaultLayer, + ModelsDev.defaultLayer, Permission.defaultLayer, Plugin.defaultLayer, Project.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts index cc67355901..8ff7bc3103 100644 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -36,7 +36,7 @@ export const ProviderRoutes = lazy(() => const svc = yield* Provider.Service const cfg = yield* Config.Service const config = yield* cfg.get() - const all = yield* Effect.promise(() => ModelsDev.get()) + const all = yield* ModelsDev.Service.use((s) => s.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined const filtered: Record = {} diff --git a/packages/opencode/test/provider/models.test.ts b/packages/opencode/test/provider/models.test.ts new file mode 100644 index 0000000000..feb5bb5893 --- /dev/null +++ b/packages/opencode/test/provider/models.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test" +import { Effect, Layer, Ref } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Global } from "@opencode-ai/core/global" +import { ModelsDev } from "../../src/provider/models" +import { it } from "../lib/effect" +import { rm, writeFile, utimes, mkdir } from "fs/promises" +import path from "path" + +// test/preload.ts pins OPENCODE_MODELS_PATH to a fixture so other tests can +// resolve providers without network. These tests need to drive the on-disk +// cache themselves and silence the eager refresh fork. Save/restore around +// the suite — never leak the mutation to subsequent test files in the same +// bun process. +const ORIGINAL_MODELS_PATH = Flag.OPENCODE_MODELS_PATH +const ORIGINAL_DISABLE_FETCH = Flag.OPENCODE_DISABLE_MODELS_FETCH +beforeAll(() => { + Flag.OPENCODE_MODELS_PATH = undefined + Flag.OPENCODE_DISABLE_MODELS_FETCH = true +}) +afterAll(() => { + Flag.OPENCODE_MODELS_PATH = ORIGINAL_MODELS_PATH + Flag.OPENCODE_DISABLE_MODELS_FETCH = ORIGINAL_DISABLE_FETCH +}) + +const cacheFile = path.join(Global.Path.cache, "models.json") + +const fixture: Record = { + acme: { + id: "acme", + name: "Acme", + env: ["ACME_API_KEY"], + models: { + "acme-1": { + id: "acme-1", + name: "Acme One", + release_date: "2026-01-01", + attachment: false, + reasoning: false, + temperature: true, + tool_call: true, + limit: { context: 128000, output: 8192 }, + }, + }, + }, +} + +const fixture2: Record = { + beta: { + id: "beta", + name: "Beta", + env: ["BETA_API_KEY"], + models: { + "beta-1": { + id: "beta-1", + name: "Beta One", + release_date: "2026-02-01", + attachment: false, + reasoning: true, + temperature: false, + tool_call: false, + limit: { context: 64000, output: 4096 }, + }, + }, + }, +} + +interface MockState { + body: string + status: number + calls: Array<{ url: string }> +} + +const makeMockClient = (state: Ref.Ref) => + HttpClient.make((request) => + Effect.gen(function* () { + yield* Ref.update(state, (s) => ({ ...s, calls: [...s.calls, { url: request.url }] })) + const s = yield* Ref.get(state) + return HttpClientResponse.fromWeb(request, new Response(s.body, { status: s.status })) + }), + ) + +const buildLayer = (state: Ref.Ref) => + // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant, + // and Effect.provide uses a process-global MemoMap by default — without fresh, + // every test would reuse the cachedInvalidateWithTTL state from the first run. + Layer.fresh(ModelsDev.layer).pipe( + Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))), + Layer.provide(AppFileSystem.defaultLayer), + ) + +const writeCache = (data: object, mtimeMs?: number) => + Effect.promise(async () => { + await mkdir(Global.Path.cache, { recursive: true }) + await writeFile(cacheFile, JSON.stringify(data)) + if (mtimeMs !== undefined) { + const t = mtimeMs / 1000 + await utimes(cacheFile, t, t) + } + }) + +const provided = (state: Ref.Ref, eff: Effect.Effect) => + eff.pipe(Effect.provide(buildLayer(state))) + +beforeEach(async () => { + await rm(cacheFile, { force: true }) +}) + +afterAll(async () => { + await rm(cacheFile, { force: true }) +}) + +const initialState: MockState = { + body: JSON.stringify(fixture), + status: 200, + calls: [], +} + +describe("ModelsDev Service", () => { + it.live("get() returns providers from disk when cache file exists", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const result = yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ) + expect(result).toEqual(fixture) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("get() returns {} when disk empty and fetch disabled", () => + Effect.gen(function* () { + const state = yield* Ref.make(initialState) + const result = yield* provided( + state, + ModelsDev.Service.use((s) => s.get()), + ) + expect(result).toEqual({}) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("get() is single-flight under concurrent calls", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const results = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + return yield* Effect.all( + [svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], + { concurrency: "unbounded" }, + ) + }), + ) + for (const result of results) expect(result).toEqual(fixture) + }), + ) + + it.live("get() caches across calls (later disk writes are ignored until invalidate)", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make(initialState) + const first = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const a = yield* svc.get() + // mutate disk between calls — cache should mask the change + yield* writeCache(fixture2) + const b = yield* svc.get() + return { a, b } + }), + ) + expect(first.a).toEqual(fixture) + expect(first.b).toEqual(fixture) + }), + ) + + it.live("refresh(true) fetches via HttpClient and updates the cache", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const result = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + const before = yield* svc.get() + yield* svc.refresh(true) + const after = yield* svc.get() + return { before, after } + }), + ) + expect(result.before).toEqual(fixture) + expect(result.after).toEqual(fixture2) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + expect(final.calls[0].url).toContain("/api.json") + }), + ) + + it.live("refresh(false) skips fetch when on-disk file is fresh", () => + Effect.gen(function* () { + // Fresh: mtime within the 5-minute TTL. + yield* writeCache(fixture, Date.now() - 1000) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + yield* provided( + state, + ModelsDev.Service.use((s) => s.refresh(false)), + ) + const final = yield* Ref.get(state) + expect(final.calls).toEqual([]) + }), + ) + + it.live("refresh(false) fetches when on-disk file is stale", () => + Effect.gen(function* () { + // Stale: mtime 10 minutes ago, beyond the 5-minute TTL. + yield* writeCache(fixture, Date.now() - 10 * 60 * 1000) + const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) }) + const after = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + yield* svc.refresh(false) + return yield* svc.get() + }), + ) + const final = yield* Ref.get(state) + expect(final.calls.length).toBe(1) + expect(after).toEqual(fixture2) + }), + ) + + it.live("refresh swallows HTTP errors and leaves cache intact", () => + Effect.gen(function* () { + yield* writeCache(fixture) + const state = yield* Ref.make({ ...initialState, status: 500, body: "boom" }) + const result = yield* provided( + state, + Effect.gen(function* () { + const svc = yield* ModelsDev.Service + yield* svc.refresh(true) + return yield* svc.get() + }), + ) + expect(result).toEqual(fixture) + // withTransientReadRetry retries 5xx, so calls may be > 1. + const final = yield* Ref.get(state) + expect(final.calls.length).toBeGreaterThanOrEqual(1) + }), + ) +}) From b3a75137654b844b6260d2ddf804affac4000475 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 18:00:11 +0000 Subject: [PATCH 037/178] chore: generate --- packages/opencode/src/provider/models.ts | 10 ++++------ packages/opencode/test/provider/models.test.ts | 7 +++---- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 3654f66c79..d3f9fa9eaf 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -127,12 +127,10 @@ export const layer: Layer.Layer Effect.succeed(undefined)), - Effect.map((v) => v as Record | undefined), - ) + const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe( + Effect.catch(() => Effect.succeed(undefined)), + Effect.map((v) => v as Record | undefined), + ) // Bundled at build time; absent in dev — `tryPromise` covers both. const loadSnapshot = Effect.tryPromise({ diff --git a/packages/opencode/test/provider/models.test.ts b/packages/opencode/test/provider/models.test.ts index feb5bb5893..7ccf126a9c 100644 --- a/packages/opencode/test/provider/models.test.ts +++ b/packages/opencode/test/provider/models.test.ts @@ -154,10 +154,9 @@ describe("ModelsDev Service", () => { state, Effect.gen(function* () { const svc = yield* ModelsDev.Service - return yield* Effect.all( - [svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], - { concurrency: "unbounded" }, - ) + return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], { + concurrency: "unbounded", + }) }), ) for (const result of results) expect(result).toEqual(fixture) From 6cd02c05c26958ef87ab6d00a2215fb8ade95e3f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 14:49:56 -0400 Subject: [PATCH 038/178] fix(telemetry): emit Tool.execute span for MCP and plugin tools (#25452) --- packages/opencode/src/session/prompt.ts | 15 ++++++++++++--- packages/opencode/src/tool/registry.ts | 11 ++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fb822ff17e..80c47d3ced 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -464,9 +464,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, { args }, ) - yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - const result: Awaited>> = yield* Effect.promise(() => - execute(args, opts), + const result: Awaited>> = yield* Effect.gen(function* () { + yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) + return yield* Effect.promise(() => execute(args, opts)) + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": key, + "tool.call_id": opts.toolCallId, + "session.id": ctx.sessionID, + "message.id": input.processor.message.id, + }, + }), ) yield* plugin.trigger( "tool.execute.after", diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index a9a853e504..ebe3bb530c 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -154,7 +154,16 @@ export const layer: Layer.Layer< ...(out.truncated && { outputPath: out.outputPath }), }, } - }), + }).pipe( + Effect.withSpan("Tool.execute", { + attributes: { + "tool.name": id, + "session.id": toolCtx.sessionID, + "message.id": toolCtx.messageID, + ...(toolCtx.callID ? { "tool.call_id": toolCtx.callID } : {}), + }, + }), + ), } } From 05b82a6a30a48de393bd929de457c42fdb15c622 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 15:11:01 -0400 Subject: [PATCH 039/178] refactor(cli): drop ModelsDev Promise compat shim (#25460) --- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/providers.ts | 11 +++++++---- packages/opencode/src/provider/models.ts | 8 -------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index b31825fd99..106d484662 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -212,7 +212,7 @@ export const GithubInstallCommand = cmd({ const app = await getAppInfo() await installGitHubApp() - const providers = await ModelsDev.get().then((p) => { + const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { // TODO: add guide for copilot, for now just hide it delete p["github-copilot"] return p diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 278522555f..c383e79ce8 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -4,6 +4,9 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { ModelsDev } from "@/provider/models" + +const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())) +const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true))) import { map, pipe, sortBy, values } from "remeda" import path from "path" import os from "os" @@ -245,7 +248,7 @@ export const ProvidersListCommand = cmd({ return Object.entries(yield* auth.all()) }), ) - const database = await ModelsDev.get() + const database = await getModels() for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID @@ -334,14 +337,14 @@ export const ProvidersLoginCommand = cmd({ prompts.outro("Done") return } - await ModelsDev.refresh(true).catch(() => {}) + await refreshModels().catch(() => {}) const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const providers = await ModelsDev.get().then((x) => { + const providers = await getModels().then((x) => { const filtered: Record = {} for (const [key, value] of Object.entries(x)) { if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { @@ -505,7 +508,7 @@ export const ProvidersLogoutCommand = cmd({ prompts.log.error("No credentials found") return } - const database = await ModelsDev.get() + const database = await getModels() const selected = await prompts.select({ message: "Select provider", options: credentials.map(([key, value]) => ({ diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index d3f9fa9eaf..77e217eb7f 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -7,7 +7,6 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { Flock } from "@opencode-ai/core/util/flock" import { Hash } from "@opencode-ai/core/util/hash" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { makeRuntime } from "@/effect/run-service" import { withTransientReadRetry } from "@/util/effect-http-client" const Cost = Schema.Struct({ @@ -196,11 +195,4 @@ export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(AppFileSystem.defaultLayer), ) -// Promise-style compat for callers in Promise-context (Hono routes, legacy CLI handlers). -// makeRuntime uses the shared memoMap so this runtime's Service instance is the same one -// AppRuntime sees — Effect callers and Promise callers operate on the same cache. -const runtime = makeRuntime(Service, defaultLayer) -export const get = () => runtime.runPromise((s) => s.get()) -export const refresh = (force = false) => runtime.runPromise((s) => s.refresh(force)) - export * as ModelsDev from "./models" From 430bde9e9bb8095ef1f6fd2e6f75c9106f61f05d Mon Sep 17 00:00:00 2001 From: HyeokjaeLee Date: Sun, 3 May 2026 04:26:30 +0900 Subject: [PATCH 040/178] =?UTF-8?q?fix(instance):=20restore=20InstanceBoot?= =?UTF-8?q?strap=20init=20parameter=20for=20non-Effec=E2=80=A6=20(#25449)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Dax Raad --- packages/opencode/src/cli/bootstrap.ts | 2 ++ packages/opencode/src/cli/cmd/tui/worker.ts | 3 ++- packages/opencode/src/effect/app-runtime.ts | 16 +++++++++++++++- .../src/server/routes/instance/middleware.ts | 3 ++- .../src/server/routes/instance/project.ts | 4 ++-- packages/opencode/src/server/workspace.ts | 4 +++- 6 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index a0dd9fe2a1..da90ec4033 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,9 +1,11 @@ import { Instance } from "../project/instance" import { InstanceStore } from "../project/instance-store" +import { getBootstrapRunEffect } from "../effect/app-runtime" export async function bootstrap(directory: string, cb: () => Promise) { return Instance.provide({ directory, + init: await getBootstrapRunEffect(), fn: async () => { try { const result = await cb() diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 0f0fd693d1..dd6f7e246d 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -10,7 +10,7 @@ import { GlobalBus } from "@/bus/global" import { Flag } from "@opencode-ai/core/flag/flag" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" -import { AppRuntime } from "@/effect/app-runtime" +import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" ensureProcessMetadata("worker") @@ -77,6 +77,7 @@ export const rpc = { async checkUpgrade(input: { directory: string }) { await Instance.provide({ directory: input.directory, + init: await getBootstrapRunEffect(), fn: async () => { await upgrade().catch(() => {}) }, diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index bbf1f4f8de..66f3a9b378 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -1,4 +1,4 @@ -import { Layer, ManagedRuntime } from "effect" +import { Effect, Layer, ManagedRuntime } from "effect" import { attach } from "./run-service" import * as Observability from "@opencode-ai/core/effect/observability" @@ -40,6 +40,7 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" @@ -93,6 +94,7 @@ export const AppLayer = Layer.mergeAll( Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, + InstanceBootstrap.defaultLayer, InstanceStore.defaultLayer, Project.defaultLayer, Vcs.defaultLayer, @@ -130,3 +132,15 @@ export const AppRuntime: Runtime = { }, dispose: () => rt.dispose(), } + +let bootstrapRun: Promise> +export function getBootstrapRunEffect(): Promise> { + if (!bootstrapRun) { + bootstrapRun = AppRuntime.runPromise( + Effect.gen(function* () { + return (yield* InstanceBootstrap.Service).run + }), + ) + } + return bootstrapRun +} diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index 622d6296f0..db7b9b52f9 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,6 +1,6 @@ import type { MiddlewareHandler } from "hono" import { Instance } from "@/project/instance" -import { AppRuntime } from "@/effect/app-runtime" +import { getBootstrapRunEffect } from "@/effect/app-runtime" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceID } from "@/control-plane/schema" @@ -23,6 +23,7 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler async fn() { return Instance.provide({ directory, + init: await getBootstrapRunEffect(), async fn() { return next() }, diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 04cc432d08..dbca75c195 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -8,7 +8,7 @@ import z from "zod" import { ProjectID } from "@/project/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { AppRuntime } from "@/effect/app-runtime" +import { getBootstrapRunEffect } from "@/effect/app-runtime" import { jsonRequest, runRequest } from "./trace" export const ProjectRoutes = lazy(() => @@ -82,7 +82,7 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await InstanceStore.reloadInstance({ directory: dir, worktree: dir, project: next }) + await InstanceStore.reloadInstance({ directory: dir, worktree: dir, project: next, init: await getBootstrapRunEffect() }) return c.json(next) }, ) diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 06930d07ca..0036c9ab46 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -5,10 +5,10 @@ import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" +import { getBootstrapRunEffect, AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" -import { AppRuntime } from "@/effect/app-runtime" import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import { ServerProxy } from "./proxy" @@ -94,11 +94,13 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware const target = await adapter.target(workspace) if (target.type === "local") { + const init = await getBootstrapRunEffect() return WorkspaceContext.provide({ workspaceID: WorkspaceID.make(workspaceID), fn: () => Instance.provide({ directory: target.directory, + init, async fn() { return next() }, From c444e971b01fadeabf63625c0ab29e41c597b079 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 19:27:24 +0000 Subject: [PATCH 041/178] chore: generate --- .../src/server/routes/instance/project.ts | 7 +- packages/sdk/js/src/v2/gen/types.gen.ts | 128 +++---- packages/sdk/openapi.json | 360 +++++++++--------- 3 files changed, 250 insertions(+), 245 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index dbca75c195..01a45c2fb9 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -82,7 +82,12 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await InstanceStore.reloadInstance({ directory: dir, worktree: dir, project: next, init: await getBootstrapRunEffect() }) + await InstanceStore.reloadInstance({ + directory: dir, + worktree: dir, + project: next, + init: await getBootstrapRunEffect(), + }) return c.json(next) }, ) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e46f8e04f0..b925ec6096 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -33,6 +33,13 @@ export type EventProjectUpdated = { properties: Project } +export type EventServerInstanceDisposed = { + type: "server.instance.disposed" + properties: { + directory: string + } +} + export type EventServerConnected = { type: "server.connected" properties: { @@ -47,10 +54,18 @@ export type EventGlobalDisposed = { } } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" +export type EventFileEdited = { + type: "file.edited" properties: { - directory: string + file: string + } +} + +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" } } @@ -215,53 +230,6 @@ export type EventInstallationUpdateAvailable = { } } -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -484,6 +452,38 @@ export type EventVcsBranchUpdated = { } } +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + export type EventWorktreeReady = { type: "worktree.ready" properties: { @@ -1112,9 +1112,11 @@ export type GlobalEvent = { workspace?: string payload: | EventProjectUpdated + | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed - | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -1124,12 +1126,6 @@ export type GlobalEvent = { | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventFileEdited - | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -1145,6 +1141,10 @@ export type GlobalEvent = { | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated @@ -2055,9 +2055,11 @@ export type File = { export type Event = | EventProjectUpdated + | EventServerInstanceDisposed | EventServerConnected | EventGlobalDisposed - | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -2067,12 +2069,6 @@ export type Event = | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventFileEdited - | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -2088,6 +2084,10 @@ export type Event = | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 930fd8c92a..cfd8277a3b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7674,6 +7674,25 @@ }, "required": ["type", "properties"] }, + "Event.server.instance.disposed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "server.instance.disposed" + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"] + } + }, + "required": ["type", "properties"] + }, "Event.server.connected": { "type": "object", "properties": { @@ -7702,21 +7721,44 @@ }, "required": ["type", "properties"] }, - "Event.server.instance.disposed": { + "Event.file.edited": { "type": "object", "properties": { "type": { "type": "string", - "const": "server.instance.disposed" + "const": "file.edited" }, "properties": { "type": "object", "properties": { - "directory": { + "file": { "type": "string" } }, - "required": ["directory"] + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] } }, "required": ["type", "properties"] @@ -8183,144 +8225,6 @@ }, "required": ["type", "properties"] }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "QuestionOption": { "type": "object", "properties": { @@ -8839,6 +8743,102 @@ }, "required": ["type", "properties"] }, + "Event.workspace.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, "Event.worktree.ready": { "type": "object", "properties": { @@ -10960,6 +10960,9 @@ { "$ref": "#/components/schemas/Event.project.updated" }, + { + "$ref": "#/components/schemas/Event.server.instance.disposed" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -10967,7 +10970,10 @@ "$ref": "#/components/schemas/Event.global.disposed" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -10996,24 +11002,6 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -11059,6 +11047,18 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, { "$ref": "#/components/schemas/Event.worktree.ready" }, @@ -13253,6 +13253,9 @@ { "$ref": "#/components/schemas/Event.project.updated" }, + { + "$ref": "#/components/schemas/Event.server.instance.disposed" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -13260,7 +13263,10 @@ "$ref": "#/components/schemas/Event.global.disposed" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -13289,24 +13295,6 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -13352,6 +13340,18 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, { "$ref": "#/components/schemas/Event.worktree.ready" }, From 43e20874f4e1e0221220c40ba8115375ac176bba Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 2 May 2026 19:53:06 +0000 Subject: [PATCH 042/178] sync release versions for v1.14.33 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 2efb1208c3..12677ea976 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.32", + "version": "1.14.33", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.32", + "version": "1.14.33", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 7196ddd4fd..5f4d79e44f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.32", + "version": "1.14.33", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 45eb7d0b70..cb5b4bf9a4 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index e94be94983..bfb7f7db8f 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.32", + "version": "1.14.33", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index c8590d6aad..f6072bd379 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.32", + "version": "1.14.33", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index f72d7f100b..d73a23e081 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.32", + "version": "1.14.33", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index b1e8aa635a..4ba8d1401b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.32", + "version": "1.14.33", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 5089278bfb..7a26516a99 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f16bf93687..1327423e51 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index b4c487f2f8..16e142b9cf 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.32", + "version": "1.14.33", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index ecc7e8f6bb..d9e71219f5 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.32" +version = "1.14.33" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.32/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 52dfab2adf..1eb790cced 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.32", + "version": "1.14.33", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 706986e426..8c5aa34998 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.32", + "version": "1.14.33", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2a96f1b8f3..d6bfdd844b 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 8729c96a55..de69e685c5 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 32d830bba7..04b996aca7 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 59039f05be..cd210c4d61 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.32", + "version": "1.14.33", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index ab8031cf95..c346fe5e7e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.32", + "version": "1.14.33", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 185bc93399..67617771f0 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.32", + "version": "1.14.33", "publisher": "sst-dev", "repository": { "type": "git", From 8396d6b016d04ce763bcf8887eb2de5a9b94205b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 17:01:46 -0400 Subject: [PATCH 043/178] refactor(cli): convert pr command to effectCmd (#25465) --- packages/opencode/src/cli/cmd/pr.ts | 184 ++++++++++++---------------- 1 file changed, 75 insertions(+), 109 deletions(-) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index f392bab4c8..8a5645e679 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -1,11 +1,11 @@ +import { Effect } from "effect" import { UI } from "../ui" -import { cmd } from "./cmd" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd, fail } from "../effect-cmd" import { Git } from "@/git" -import { Instance } from "@/project/instance" +import { InstanceRef } from "@/effect/instance-ref" import { Process } from "@/util/process" -export const PrCommand = cmd({ +export const PrCommand = effectCmd({ command: "pr ", describe: "fetch and checkout a GitHub PR branch, then run opencode", builder: (yargs) => @@ -14,125 +14,91 @@ export const PrCommand = cmd({ describe: "PR number to checkout", demandOption: true, }), - async handler(args) { - await Instance.provide({ - directory: process.cwd(), - async fn() { - const project = Instance.project - if (project.vcs !== "git") { - UI.error("Could not find git repository. Please run this command from a git repository.") - process.exit(1) - } + handler: Effect.fn("Cli.pr")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* fail("Could not load instance context") + if (ctx.project.vcs !== "git") { + return yield* fail("Could not find git repository. Please run this command from a git repository.") + } - const prNumber = args.number - const localBranchName = `pr/${prNumber}` - UI.println(`Fetching and checking out PR #${prNumber}...`) + const git = yield* Git.Service + const worktree = ctx.worktree - // Use gh pr checkout with custom branch name - const result = await Process.run( - ["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], - { - nothrow: true, - }, - ) + const prNumber = args.number + const localBranchName = `pr/${prNumber}` + UI.println(`Fetching and checking out PR #${prNumber}...`) - if (result.code !== 0) { - UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`) - process.exit(1) - } + const checkout = yield* Effect.promise(() => + Process.run(["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"], { nothrow: true }), + ) + if (checkout.code !== 0) { + return yield* fail(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`) + } - // Fetch PR info for fork handling and session link detection - const prInfoResult = await Process.text( - [ - "gh", - "pr", - "view", - `${prNumber}`, - "--json", - "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body", - ], - { nothrow: true }, - ) + const prInfoResult = yield* Effect.promise(() => + Process.text( + ["gh", "pr", "view", `${prNumber}`, "--json", "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body"], + { nothrow: true }, + ), + ) - let sessionId: string | undefined + let sessionId: string | undefined - if (prInfoResult.code === 0) { - const prInfoText = prInfoResult.text - if (prInfoText.trim()) { - const prInfo = JSON.parse(prInfoText) + if (prInfoResult.code === 0 && prInfoResult.text.trim()) { + const prInfo = JSON.parse(prInfoResult.text) - // Handle fork PRs - if (prInfo && prInfo.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) { - const forkOwner = prInfo.headRepositoryOwner.login - const forkName = prInfo.headRepository.name - const remoteName = forkOwner + if (prInfo?.isCrossRepository && prInfo.headRepository && prInfo.headRepositoryOwner) { + const forkOwner = prInfo.headRepositoryOwner.login + const forkName = prInfo.headRepository.name + const remoteName = forkOwner - // Check if remote already exists - const remotes = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })), - ).then((x) => x.text().trim()) - if (!remotes.split("\n").includes(remoteName)) { - await AppRuntime.runPromise( - Git.Service.use((git) => - git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { - cwd: Instance.worktree, - }), - ), - ) - UI.println(`Added fork remote: ${remoteName}`) - } + const remotes = (yield* git.run(["remote"], { cwd: worktree })).text().trim() + if (!remotes.split("\n").includes(remoteName)) { + yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { cwd: worktree }) + UI.println(`Added fork remote: ${remoteName}`) + } - // Set upstream to the fork so pushes go there - const headRefName = prInfo.headRefName - await AppRuntime.runPromise( - Git.Service.use((git) => - git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], { - cwd: Instance.worktree, - }), - ), - ) - } + yield* git.run( + ["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], + { cwd: worktree }, + ) + } - // Check for opencode session link in PR body - if (prInfo && prInfo.body) { - const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) - if (sessionMatch) { - const sessionUrl = sessionMatch[0] - UI.println(`Found opencode session: ${sessionUrl}`) - UI.println(`Importing session...`) + if (prInfo?.body) { + const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) + if (sessionMatch) { + const sessionUrl = sessionMatch[0] + UI.println(`Found opencode session: ${sessionUrl}`) + UI.println(`Importing session...`) - const importResult = await Process.text(["opencode", "import", sessionUrl], { - nothrow: true, - }) - if (importResult.code === 0) { - const importOutput = importResult.text.trim() - // Extract session ID from the output (format: "Imported session: ") - const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/) - if (sessionIdMatch) { - sessionId = sessionIdMatch[1] - UI.println(`Session imported: ${sessionId}`) - } - } - } + const importResult = yield* Effect.promise(() => Process.text(["opencode", "import", sessionUrl], { nothrow: true })) + if (importResult.code === 0) { + const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/) + if (sessionIdMatch) { + sessionId = sessionIdMatch[1] + UI.println(`Session imported: ${sessionId}`) } } } + } + } - UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) - UI.println() - UI.println("Starting opencode...") - UI.println() + UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) + UI.println() + UI.println("Starting opencode...") + UI.println() - const opencodeArgs = sessionId ? ["-s", sessionId] : [] - const opencodeProcess = Process.spawn(["opencode", ...opencodeArgs], { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - cwd: process.cwd(), - }) - const code = await opencodeProcess.exited - if (code !== 0) throw new Error(`opencode exited with code ${code}`) - }, - }) - }, + const opencodeArgs = sessionId ? ["-s", sessionId] : [] + const code = yield* Effect.promise(() => + Process.spawn(["opencode", ...opencodeArgs], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + cwd: process.cwd(), + }).exited, + ) + // Match legacy throw semantics — propagate as a defect so the top-level + // index.ts catch handles it identically (exit 1, "Unexpected error" banner). + if (code !== 0) return yield* Effect.die(new Error(`opencode exited with code ${code}`)) + }), }) From b314781a1a69754da6047ba87847601ef2b379d4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 21:02:46 +0000 Subject: [PATCH 044/178] chore: generate --- packages/opencode/src/cli/cmd/pr.ts | 39 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index 8a5645e679..4209722357 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -37,7 +37,14 @@ export const PrCommand = effectCmd({ const prInfoResult = yield* Effect.promise(() => Process.text( - ["gh", "pr", "view", `${prNumber}`, "--json", "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body"], + [ + "gh", + "pr", + "view", + `${prNumber}`, + "--json", + "headRepository,headRepositoryOwner,isCrossRepository,headRefName,body", + ], { nothrow: true }, ), ) @@ -54,14 +61,15 @@ export const PrCommand = effectCmd({ const remotes = (yield* git.run(["remote"], { cwd: worktree })).text().trim() if (!remotes.split("\n").includes(remoteName)) { - yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { cwd: worktree }) + yield* git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], { + cwd: worktree, + }) UI.println(`Added fork remote: ${remoteName}`) } - yield* git.run( - ["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], - { cwd: worktree }, - ) + yield* git.run(["branch", `--set-upstream-to=${remoteName}/${prInfo.headRefName}`, localBranchName], { + cwd: worktree, + }) } if (prInfo?.body) { @@ -71,7 +79,9 @@ export const PrCommand = effectCmd({ UI.println(`Found opencode session: ${sessionUrl}`) UI.println(`Importing session...`) - const importResult = yield* Effect.promise(() => Process.text(["opencode", "import", sessionUrl], { nothrow: true })) + const importResult = yield* Effect.promise(() => + Process.text(["opencode", "import", sessionUrl], { nothrow: true }), + ) if (importResult.code === 0) { const sessionIdMatch = importResult.text.trim().match(/Imported session: ([a-zA-Z0-9_-]+)/) if (sessionIdMatch) { @@ -89,13 +99,14 @@ export const PrCommand = effectCmd({ UI.println() const opencodeArgs = sessionId ? ["-s", sessionId] : [] - const code = yield* Effect.promise(() => - Process.spawn(["opencode", ...opencodeArgs], { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - cwd: process.cwd(), - }).exited, + const code = yield* Effect.promise( + () => + Process.spawn(["opencode", ...opencodeArgs], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + cwd: process.cwd(), + }).exited, ) // Match legacy throw semantics — propagate as a defect so the top-level // index.ts catch handles it identically (exit 1, "Unexpected error" banner). From e318e173d8cc9c9bc92c2171d0d858edf525db48 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 17:45:41 -0400 Subject: [PATCH 045/178] refactor(cli): convert export command to effectCmd (#25471) --- packages/opencode/src/cli/cmd/export.ts | 121 +++++++++++------------- 1 file changed, 57 insertions(+), 64 deletions(-) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 62ba20e2ca..5ff282b543 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -1,13 +1,13 @@ -import type { Argv } from "yargs" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" import { SessionID } from "../../session/schema" -import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" +import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" import * as prompts from "@clack/prompts" import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" function redact(kind: string, id: string, value: string) { return value.trim() ? `[redacted:${kind}:${id}]` : value @@ -220,11 +220,11 @@ function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] }) } } -export const ExportCommand = cmd({ +export const ExportCommand = effectCmd({ command: "export [sessionID]", describe: "export session data as JSON", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("sessionID", { describe: "session id to export", type: "string", @@ -232,72 +232,65 @@ export const ExportCommand = cmd({ .option("sanitize", { describe: "redact sensitive transcript and file data", type: "boolean", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined - process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`) + }), + handler: Effect.fn("Cli.export")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* run(args).pipe(Effect.ensuring(store.dispose(ctx))) + }), +}) - if (!sessionID) { - UI.empty() - prompts.intro("Export session", { - output: process.stderr, - }) +const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) { + const svc = yield* Session.Service + let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined + process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`) - const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list())) + if (!sessionID) { + UI.empty() + prompts.intro("Export session", { output: process.stderr }) - if (sessions.length === 0) { - prompts.log.error("No sessions found", { - output: process.stderr, - }) - prompts.outro("Done", { - output: process.stderr, - }) - return - } + const sessions = yield* svc.list() + + if (sessions.length === 0) { + prompts.log.error("No sessions found", { output: process.stderr }) + prompts.outro("Done", { output: process.stderr }) + return + } - sessions.sort((a, b) => b.time.updated - a.time.updated) + sessions.sort((a, b) => b.time.updated - a.time.updated) - const selectedSession = await prompts.autocomplete({ - message: "Select session to export", - maxItems: 10, - options: sessions.map((session) => ({ - label: session.title, - value: session.id, - hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`, - })), - output: process.stderr, - }) + const selectedSession = yield* Effect.promise(() => + prompts.autocomplete({ + message: "Select session to export", + maxItems: 10, + options: sessions.map((session) => ({ + label: session.title, + value: session.id, + hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`, + })), + output: process.stderr, + }), + ) - if (prompts.isCancel(selectedSession)) { - throw new UI.CancelledError() - } + if (prompts.isCancel(selectedSession)) { + return yield* Effect.die(new UI.CancelledError()) + } - sessionID = selectedSession + sessionID = selectedSession - prompts.outro("Exporting session...", { - output: process.stderr, - }) - } + prompts.outro("Exporting session...", { output: process.stderr }) + } - try { - const sessionInfo = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID!))) - const messages = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID: sessionInfo.id })), - ) + // Match legacy try/catch — catches both typed failures and defects + // (Session.Service.get throws NotFoundError as a defect, not a typed E). + return yield* Effect.gen(function* () { + const sessionInfo = yield* svc.get(sessionID!) + const messages = yield* svc.messages({ sessionID: sessionInfo.id }) - const exportData = { - info: sessionInfo, - messages, - } + const exportData = { info: sessionInfo, messages } - process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2)) - process.stdout.write(EOL) - } catch { - UI.error(`Session not found: ${sessionID!}`) - process.exit(1) - } - }) - }, + process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2)) + process.stdout.write(EOL) + }).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`))) }) From 0c816eb4b1de0c5999d8c5349a34deb52a250d7d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 17:55:13 -0400 Subject: [PATCH 046/178] refactor(cli): convert plugin command to effectCmd (#25473) --- packages/opencode/src/cli/cmd/plug.ts | 41 ++++++++++++------------- packages/opencode/src/cli/effect-cmd.ts | 2 ++ 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/plug.ts b/packages/opencode/src/cli/cmd/plug.ts index 1ac0b071dd..1529e9b71d 100644 --- a/packages/opencode/src/cli/cmd/plug.ts +++ b/packages/opencode/src/cli/cmd/plug.ts @@ -1,16 +1,16 @@ import { intro, log, outro, spinner } from "@clack/prompts" -import type { Argv } from "yargs" +import { Effect } from "effect" import { ConfigPaths } from "@/config/paths" import { Global } from "@opencode-ai/core/global" import { installPlugin, patchPluginConfig, readPluginManifest } from "../../plugin/install" import { resolvePluginTarget } from "../../plugin/shared" -import { Instance } from "../../project/instance" import { errorMessage } from "../../util/error" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" type Spin = { start: (msg: string) => void @@ -175,12 +175,12 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps } } -export const PluginCommand = cmd({ +export const PluginCommand = effectCmd({ command: "plugin ", aliases: ["plug"], describe: "install plugin and update config", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .positional("module", { type: "string", describe: "npm module name", @@ -196,9 +196,8 @@ export const PluginCommand = cmd({ type: "boolean", default: false, describe: "replace existing plugin version", - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.plug")(function* (args) { const mod = String(args.module ?? "").trim() if (!mod) { UI.error("module is required") @@ -214,20 +213,18 @@ export const PluginCommand = cmd({ global: Boolean(args.global), force: Boolean(args.force), }) - let ok = true - - await Instance.provide({ - directory: process.cwd(), - fn: async () => { - ok = await run({ - vcs: Instance.project.vcs, - worktree: Instance.worktree, - directory: Instance.directory, - }) - }, - }) + + const ctx = yield* InstanceRef + if (!ctx) return + const ok = yield* Effect.promise(() => + run({ + vcs: ctx.project.vcs, + worktree: ctx.worktree, + directory: ctx.directory, + }), + ) outro("Done") if (!ok) process.exitCode = 1 - }, + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index cc4dd2ed7e..29f750d160 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -31,6 +31,7 @@ export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError( */ export const effectCmd = (opts: { command: string | readonly string[] + aliases?: string | readonly string[] describe: string | false builder?: (yargs: Argv) => Argv /** Defaults to process.cwd(). Override for commands that take a directory positional. */ @@ -39,6 +40,7 @@ export const effectCmd = (opts: { }) => cmd<{}, Args>({ command: opts.command, + aliases: opts.aliases, describe: opts.describe, builder: opts.builder as never, async handler(rawArgs) { From 79b6ce5db47d9107711dbc3b8bf02ebabe5b47ae Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 17:56:32 -0400 Subject: [PATCH 047/178] refactor(cli): convert import command to effectCmd (#25467) --- packages/opencode/src/cli/cmd/import.ts | 256 ++++++++++++------------ 1 file changed, 133 insertions(+), 123 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index d55aba091a..8d19376662 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -1,17 +1,15 @@ -import type { Argv } from "yargs" import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { Session } from "@/session/session" import { MessageV2 } from "../../session/message-v2" -import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" +import { CliError, effectCmd } from "../effect-cmd" import { Database } from "@/storage/db" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" -import { Instance } from "../../project/instance" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" import { ShareNext } from "@/share/share-next" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" -import { AppRuntime } from "@/effect/app-runtime" -import { Schema } from "effect" +import { Effect, Schema } from "effect" const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info) const decodePart = Schema.decodeUnknownSync(MessageV2.Part) @@ -78,135 +76,147 @@ export function transformShareData(shareData: ShareData[]): { } } -export const ImportCommand = cmd({ +type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> } + +export const ImportCommand = effectCmd({ command: "import ", describe: "import session data from JSON file or URL", - builder: (yargs: Argv) => { - return yargs.positional("file", { + builder: (yargs) => + yargs.positional("file", { describe: "path to JSON file or share URL", type: "string", demandOption: true, + }), + handler: Effect.fn("Cli.import")(function* (args) { + // effectCmd always provides InstanceRef via InstanceStore.Service.provide; this is an invariant. + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + const store = yield* InstanceStore.Service + // Ensure store.dispose runs disposers and emits server.instance.disposed + // on every exit path: success, early return, typed failure, defect, interrupt. + return yield* runImport(args.file, ctx.project.id).pipe(Effect.ensuring(store.dispose(ctx))) + }), +}) + +const runImport = Effect.fn("Cli.import.body")(function* (file: string, projectID: string) { + const share = yield* ShareNext.Service + + let exportData: ExportData | undefined + + const isUrl = file.startsWith("http://") || file.startsWith("https://") + + if (isUrl) { + const slug = parseShareUrl(file) + if (!slug) { + const baseUrl = yield* Effect.orDie(share.url()) + process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) + process.stdout.write(EOL) + return + } + + const baseUrl = new URL(file).origin + const req = yield* Effect.orDie(share.request()) + const headers = shouldAttachShareAuthHeaders(file, req.baseUrl) ? req.headers : {} + + const tryFetch = (url: string) => + Effect.tryPromise({ + try: () => fetch(url, { headers }), + catch: (e) => + new CliError({ + message: `Failed to fetch share data: ${e instanceof Error ? e.message : String(e)}`, + }), + }) + + const dataPath = req.api.data(slug) + let response = yield* tryFetch(`${baseUrl}${dataPath}`) + + if (!response.ok && dataPath !== `/api/share/${slug}/data`) { + response = yield* tryFetch(`${baseUrl}/api/share/${slug}/data`) + } + + if (!response.ok) { + process.stdout.write(`Failed to fetch share data: ${response.statusText}`) + process.stdout.write(EOL) + return + } + + const shareData = yield* Effect.tryPromise({ + try: () => response.json() as Promise, + catch: () => new CliError({ message: "Share data was not valid JSON" }), }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - let exportData: - | { - info: SDKSession - messages: Array<{ - info: Message - parts: Part[] - }> - } - | undefined - - const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://") - - if (isUrl) { - const slug = parseShareUrl(args.file) - if (!slug) { - const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url())) - process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/`) - process.stdout.write(EOL) - return - } - - const parsed = new URL(args.file) - const baseUrl = parsed.origin - const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request())) - const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {} - - const dataPath = req.api.data(slug) - let response = await fetch(`${baseUrl}${dataPath}`, { - headers, - }) + const transformed = transformShareData(shareData) - if (!response.ok && dataPath !== `/api/share/${slug}/data`) { - response = await fetch(`${baseUrl}/api/share/${slug}/data`, { - headers, - }) - } - - if (!response.ok) { - process.stdout.write(`Failed to fetch share data: ${response.statusText}`) - process.stdout.write(EOL) - return - } - - const shareData: ShareData[] = await response.json() - const transformed = transformShareData(shareData) - - if (!transformed) { - process.stdout.write(`Share not found or empty: ${slug}`) - process.stdout.write(EOL) - return - } - - exportData = transformed - } else { - exportData = await Filesystem.readJson>(args.file).catch(() => undefined) - if (!exportData) { - process.stdout.write(`File not found: ${args.file}`) - process.stdout.write(EOL) - return - } - } + if (!transformed) { + process.stdout.write(`Share not found or empty: ${slug}`) + process.stdout.write(EOL) + return + } - if (!exportData) { - process.stdout.write(`Failed to read session data`) - process.stdout.write(EOL) - return - } + exportData = transformed + } else { + exportData = yield* Effect.promise(() => + Filesystem.readJson>(file).catch(() => undefined), + ) + if (!exportData) { + process.stdout.write(`File not found: ${file}`) + process.stdout.write(EOL) + return + } + } + + if (!exportData) { + process.stdout.write(`Failed to read session data`) + process.stdout.write(EOL) + return + } - const info = Schema.decodeUnknownSync(Session.Info)({ - ...exportData.info, - projectID: Instance.project.id, - }) as Session.Info - const row = Session.toRow(info) + const info = Schema.decodeUnknownSync(Session.Info)({ + ...exportData.info, + projectID, + }) as Session.Info + const row = Session.toRow(info) + Database.use((db) => + db + .insert(SessionTable) + .values(row) + .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } }) + .run(), + ) + + for (const msg of exportData.messages) { + const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info + const { id, sessionID: _, ...msgData } = msgInfo + Database.use((db) => + db + .insert(MessageTable) + .values({ + id, + session_id: row.id, + time_created: msgInfo.time?.created ?? Date.now(), + data: msgData, + }) + .onConflictDoNothing() + .run(), + ) + + for (const part of msg.parts) { + const partInfo = decodePart(part) as MessageV2.Part + const { id: partId, sessionID: _s, messageID, ...partData } = partInfo Database.use((db) => db - .insert(SessionTable) - .values(row) - .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } }) + .insert(PartTable) + .values({ + id: partId, + message_id: messageID, + session_id: row.id, + data: partData, + }) + .onConflictDoNothing() .run(), ) + } + } - for (const msg of exportData.messages) { - const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info - const { id, sessionID: _, ...msgData } = msgInfo - Database.use((db) => - db - .insert(MessageTable) - .values({ - id, - session_id: row.id, - time_created: msgInfo.time?.created ?? Date.now(), - data: msgData, - }) - .onConflictDoNothing() - .run(), - ) - - for (const part of msg.parts) { - const partInfo = decodePart(part) as MessageV2.Part - const { id: partId, sessionID: _s, messageID, ...partData } = partInfo - Database.use((db) => - db - .insert(PartTable) - .values({ - id: partId, - message_id: messageID, - session_id: row.id, - data: partData, - }) - .onConflictDoNothing() - .run(), - ) - } - } - - process.stdout.write(`Imported session: ${exportData.info.id}`) - process.stdout.write(EOL) - }) - }, + process.stdout.write(`Imported session: ${exportData.info.id}`) + process.stdout.write(EOL) }) From c1686c6ddc8c05a452b8f5771f717c7bb1401114 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 18:01:06 -0400 Subject: [PATCH 048/178] refactor(cli): convert stats command to effectCmd (#25474) --- packages/opencode/src/cli/cmd/stats.ts | 61 ++++++++++++++------------ 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 6d00fdf9f4..9a8843160b 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,11 +1,11 @@ -import type { Argv } from "yargs" -import { cmd } from "./cmd" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" import { Session } from "@/session/session" -import { bootstrap } from "../bootstrap" import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" -import { Instance } from "../../project/instance" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" import { AppRuntime } from "@/effect/app-runtime" interface SessionStats { @@ -47,11 +47,11 @@ interface SessionStats { medianTokensPerSession: number } -export const StatsCommand = cmd({ +export const StatsCommand = effectCmd({ command: "stats", describe: "show token usage and cost statistics", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .option("days", { describe: "show stats for the last N days (default: all time)", type: "number", @@ -66,34 +66,39 @@ export const StatsCommand = cmd({ .option("project", { describe: "filter by project (default: all projects, empty string: current project)", type: "string", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const stats = await aggregateSessionStats(args.days, args.project) - - let modelLimit: number | undefined - if (args.models === true) { - modelLimit = Infinity - } else if (typeof args.models === "number") { - modelLimit = args.models - } - - displayStats(stats, args.tools, modelLimit) - }) - }, + }), + handler: Effect.fn("Cli.stats")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* run(args, ctx.project).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -async function getCurrentProject(): Promise { - return Instance.project -} +const run = (args: { days?: number; tools?: number; models?: unknown; project?: string }, currentProject: Project.Info) => + Effect.promise(async () => { + const stats = await aggregateSessionStats(args.days, args.project, currentProject) + + let modelLimit: number | undefined + if (args.models === true) { + modelLimit = Infinity + } else if (typeof args.models === "number") { + modelLimit = args.models + } + + displayStats(stats, args.tools, modelLimit) + }) async function getAllSessions(): Promise { const rows = Database.use((db) => db.select().from(SessionTable).all()) return rows.map((row) => Session.fromRow(row)) } -export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise { +export async function aggregateSessionStats( + days?: number, + projectFilter?: string, + currentProject?: Project.Info, +): Promise { const sessions = await getAllSessions() const MS_IN_DAY = 24 * 60 * 60 * 1000 @@ -117,7 +122,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin if (projectFilter !== undefined) { if (projectFilter === "") { - const currentProject = await getCurrentProject() + if (!currentProject) throw new Error("currentProject required when projectFilter is empty string") filteredSessions = filteredSessions.filter((session) => session.projectID === currentProject.id) } else { filteredSessions = filteredSessions.filter((session) => session.projectID === projectFilter) From dfe1325fca27612bab879102eb8974270cc13407 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 22:02:14 +0000 Subject: [PATCH 049/178] chore: generate --- packages/opencode/src/cli/cmd/stats.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 9a8843160b..966eb5f662 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -75,7 +75,10 @@ export const StatsCommand = effectCmd({ }), }) -const run = (args: { days?: number; tools?: number; models?: unknown; project?: string }, currentProject: Project.Info) => +const run = ( + args: { days?: number; tools?: number; models?: unknown; project?: string }, + currentProject: Project.Info, +) => Effect.promise(async () => { const stats = await aggregateSessionStats(args.days, args.project, currentProject) From 1986a6e81775b5a716c44ae0b6fc4ba8d1ef32cc Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 18:15:28 -0400 Subject: [PATCH 050/178] refactor(cli): convert session subcommands to effectCmd (#25483) --- packages/opencode/src/cli/cmd/session.ts | 102 +++++++++++------------ 1 file changed, 49 insertions(+), 53 deletions(-) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 52a3d7204e..dbf27ccc6c 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -1,8 +1,9 @@ import type { Argv } from "yargs" +import { Effect } from "effect" import { cmd } from "./cmd" +import { effectCmd, fail } from "../effect-cmd" import { Session } from "@/session/session" import { SessionID } from "../../session/schema" -import { bootstrap } from "../bootstrap" import { UI } from "../ui" import { Locale } from "@/util/locale" import { Flag } from "@opencode-ai/core/flag/flag" @@ -11,7 +12,8 @@ import { Process } from "@/util/process" import { EOL } from "os" import path from "path" import { which } from "../../util/which" -import { AppRuntime } from "@/effect/app-runtime" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] @@ -47,36 +49,35 @@ export const SessionCommand = cmd({ async handler() {}, }) -export const SessionDeleteCommand = cmd({ +export const SessionDeleteCommand = effectCmd({ command: "delete ", describe: "delete a session", - builder: (yargs: Argv) => { - return yargs.positional("sessionID", { + builder: (yargs) => + yargs.positional("sessionID", { describe: "session ID to delete", type: "string", demandOption: true, - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { + }), + handler: Effect.fn("Cli.session.delete")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const svc = yield* Session.Service const sessionID = SessionID.make(args.sessionID) - try { - await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID))) - } catch { - UI.error(`Session not found: ${args.sessionID}`) - process.exit(1) - } - await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID))) + // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. + yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) + yield* svc.remove(sessionID) UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -export const SessionListCommand = cmd({ +export const SessionListCommand = effectCmd({ command: "list", describe: "list sessions", - builder: (yargs: Argv) => { - return yargs + builder: (yargs) => + yargs .option("max-count", { alias: "n", describe: "limit to N most recent sessions", @@ -87,47 +88,42 @@ export const SessionListCommand = cmd({ type: "string", choices: ["table", "json"], default: "table", - }) - }, - handler: async (args) => { - await bootstrap(process.cwd(), async () => { - const sessions = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })), - ) - - if (sessions.length === 0) { - return - } + }), + handler: Effect.fn("Cli.session.list")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })) - let output: string - if (args.format === "json") { - output = formatSessionJSON(sessions) - } else { - output = formatSessionTable(sessions) - } + if (sessions.length === 0) return + + const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions) const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" if (shouldPaginate) { - const proc = Process.spawn(pagerCmd(), { - stdin: "pipe", - stdout: "inherit", - stderr: "inherit", + yield* Effect.promise(async () => { + const proc = Process.spawn(pagerCmd(), { + stdin: "pipe", + stdout: "inherit", + stderr: "inherit", + }) + + if (!proc.stdin) { + console.log(output) + return + } + + proc.stdin.write(output) + proc.stdin.end() + await proc.exited }) - - if (!proc.stdin) { - console.log(output) - return - } - - proc.stdin.write(output) - proc.stdin.end() - await proc.exited } else { console.log(output) } - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) function formatSessionTable(sessions: Session.Info[]): string { From 3f459819ba6d3224eef6bbc88b4f239fa89491af Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 3 May 2026 09:18:48 +1000 Subject: [PATCH 051/178] feat: refactor bash tool with shell-aware prompts for bash, pwsh+powershell, and cmd (#20039) --- packages/opencode/src/acp/agent.ts | 36 ++- packages/opencode/src/cli/cmd/github.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 7 +- .../src/cli/cmd/tui/routes/session/index.tsx | 9 +- .../cli/cmd/tui/routes/session/permission.tsx | 3 +- packages/opencode/src/session/prompt.ts | 3 +- packages/opencode/src/tool/registry.ts | 8 +- .../opencode/src/tool/{bash.ts => shell.ts} | 95 +++--- packages/opencode/src/tool/shell/id.ts | 19 ++ packages/opencode/src/tool/shell/prompt.ts | 297 ++++++++++++++++++ .../src/tool/{bash.txt => shell/shell.txt} | 60 +--- .../opencode/test/session/message-v2.test.ts | 6 +- .../opencode/test/tool/parameters.test.ts | 16 +- .../test/tool/{bash.test.ts => shell.test.ts} | 122 +++++-- 14 files changed, 506 insertions(+), 177 deletions(-) rename packages/opencode/src/tool/{bash.ts => shell.ts} (81%) create mode 100644 packages/opencode/src/tool/shell/id.ts create mode 100644 packages/opencode/src/tool/shell/prompt.ts rename packages/opencode/src/tool/{bash.txt => shell/shell.txt} (59%) rename packages/opencode/test/tool/{bash.test.ts => shell.test.ts} (92%) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index af16cba114..8bbc2427fc 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -51,6 +51,7 @@ import { LoadAPIKeyError } from "ai" import type { AssistantMessage, Event, OpencodeClient, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { ShellID } from "@/tool/shell/id" type ModeOption = { id: string; name: string; description?: string } type ModelOption = { modelId: string; name: string } @@ -144,7 +145,7 @@ export class Agent implements ACPAgent { private sessionManager: ACPSessionManager private eventAbort = new AbortController() private eventStarted = false - private bashSnapshots = new Map() + private shellSnapshots = new Map() private toolStarts = new Set() private permissionQueues = new Map>() private permissionOptions: PermissionOption[] = [ @@ -283,16 +284,16 @@ export class Agent implements ACPAgent { switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) return case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const content: ToolCallContent[] = [] if (output) { const hash = Hash.fast(output) - if (part.tool === "bash") { - if (this.bashSnapshots.get(part.callID) === hash) { + if (part.tool === ShellID.ToolID) { + if (this.shellSnapshots.get(part.callID) === hash) { await this.connection .sessionUpdate({ sessionId, @@ -311,7 +312,7 @@ export class Agent implements ACPAgent { }) return } - this.bashSnapshots.set(part.callID, hash) + this.shellSnapshots.set(part.callID, hash) } content.push({ type: "content", @@ -342,7 +343,7 @@ export class Agent implements ACPAgent { case "completed": { this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -423,7 +424,7 @@ export class Agent implements ACPAgent { } case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -837,10 +838,10 @@ export class Agent implements ACPAgent { await this.toolStart(sessionId, part) switch (part.state.status) { case "pending": - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) break case "running": - const output = this.bashOutput(part) + const output = this.shellOutput(part) const runningContent: ToolCallContent[] = [] if (output) { runningContent.push({ @@ -871,7 +872,7 @@ export class Agent implements ACPAgent { break case "completed": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) const kind = toToolKind(part.tool) const content: ToolCallContent[] = [ { @@ -951,7 +952,7 @@ export class Agent implements ACPAgent { break case "error": this.toolStarts.delete(part.callID) - this.bashSnapshots.delete(part.callID) + this.shellSnapshots.delete(part.callID) await this.connection .sessionUpdate({ sessionId, @@ -1105,8 +1106,8 @@ export class Agent implements ACPAgent { } } - private bashOutput(part: ToolPart) { - if (part.tool !== "bash") return + private shellOutput(part: ToolPart) { + if (part.tool !== ShellID.ToolID) return if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return const output = part.state.metadata["output"] if (typeof output !== "string") return @@ -1549,9 +1550,11 @@ export class Agent implements ACPAgent { function toToolKind(toolName: string): ToolKind { const tool = toolName.toLocaleLowerCase() + switch (tool) { - case "bash": + case ShellID.ToolID: return "execute" + case "webfetch": return "fetch" @@ -1576,6 +1579,7 @@ function toToolKind(toolName: string): ToolKind { function toLocations(toolName: string, input: Record): { path: string }[] { const tool = toolName.toLocaleLowerCase() + switch (tool) { case "read": case "edit": @@ -1584,7 +1588,7 @@ function toLocations(toolName: string, input: Record): { path: stri case "glob": case "grep": return input["path"] ? [{ path: input["path"] }] : [] - case "bash": + case ShellID.ToolID: return [] default: return [] diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 106d484662..a75dc31634 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -879,7 +879,7 @@ export const GithubRunCommand = cmd({ function subscribeSessionEvents() { const TOOL: Record = { todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], - bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], + bash: ["Shell", UI.Style.TEXT_DANGER_BOLD], edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], glob: ["Glob", UI.Style.TEXT_INFO_BOLD], grep: ["Grep", UI.Style.TEXT_INFO_BOLD], diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c94e962038..f73ca67175 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -22,7 +22,8 @@ import { WriteTool } from "../../tool/write" import { WebSearchTool } from "../../tool/websearch" import { TaskTool } from "../../tool/task" import { SkillTool } from "../../tool/skill" -import { BashTool } from "../../tool/bash" +import { ShellTool } from "../../tool/shell" +import { ShellID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "@/util/locale" import { AppRuntime } from "@/effect/app-runtime" @@ -175,7 +176,7 @@ function skill(info: ToolProps) { }) } -function bash(info: ToolProps) { +function shell(info: ToolProps) { const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined block( { @@ -400,7 +401,7 @@ export const RunCommand = cmd({ async function execute(sdk: OpencodeClient) { function tool(part: ToolPart) { try { - if (part.tool === "bash") return bash(props(part)) + if (part.tool === ShellID.ToolID) return shell(props(part)) if (part.tool === "glob") return glob(props(part)) if (part.tool === "grep") return grep(props(part)) if (part.tool === "read") return read(props(part)) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 8855338d1d..d43edd2dd5 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -37,7 +37,8 @@ import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" import type { ReadTool } from "@/tool/read" import type { WriteTool } from "@/tool/write" -import { BashTool } from "@/tool/bash" +import { ShellTool } from "@/tool/shell" +import { ShellID } from "@/tool/shell/id" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" @@ -1552,8 +1553,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess return ( - - + + @@ -1784,7 +1785,7 @@ function BlockTool(props: { ) } -function Bash(props: ToolProps) { +function Shell(props: ToolProps) { const { theme } = useTheme() const sync = useSync() const isRunning = createMemo(() => props.part.state.status === "running") diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 720a05ff7e..e7e4c7cea3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -15,6 +15,7 @@ import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" import { Global } from "@opencode-ai/core/global" +import { ShellID } from "@/tool/shell/id" import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" @@ -287,7 +288,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { } } - if (permission === "bash") { + if (permission === ShellID.ToolID) { const title = typeof data.description === "string" && data.description ? data.description : "Shell command" const command = typeof data.command === "string" ? data.command : "" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 80c47d3ced..9f1420388e 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -41,6 +41,7 @@ import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@/shell/shell" +import { ShellID } from "@/tool/shell/id" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { decodeDataUrl } from "@/util/data-url" @@ -789,7 +790,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the id: PartID.ascending(), messageID: msg.id, sessionID: input.sessionID, - tool: "bash", + tool: ShellID.ToolID, callID: ulid(), state: { status: "running", diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ebe3bb530c..a4eb31acc7 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,7 +1,7 @@ import { PlanExitTool } from "./plan" import { Session } from "@/session/session" import { QuestionTool } from "./question" -import { BashTool } from "./bash" +import { ShellTool } from "./shell" import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" @@ -106,7 +106,7 @@ export const layer: Layer.Layer< const plan = yield* PlanExitTool const webfetch = yield* WebFetchTool const websearch = yield* WebSearchTool - const bash = yield* BashTool + const shell = yield* ShellTool const globtool = yield* GlobTool const writetool = yield* WriteTool const edit = yield* EditTool @@ -195,7 +195,7 @@ export const layer: Layer.Layer< const tool = yield* Effect.all({ invalid: Tool.init(invalid), - bash: Tool.init(bash), + shell: Tool.init(shell), read: Tool.init(read), glob: Tool.init(globtool), grep: Tool.init(greptool), @@ -217,7 +217,7 @@ export const layer: Layer.Layer< builtin: [ tool.invalid, ...(questionEnabled ? [tool.question] : []), - tool.bash, + tool.shell, tool.read, tool.glob, tool.grep, diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/shell.ts similarity index 81% rename from packages/opencode/src/tool/bash.ts rename to packages/opencode/src/tool/shell.ts index bf00082505..bb2e4e58df 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/shell.ts @@ -1,12 +1,11 @@ -import { Schema } from "effect" -import { PositiveInt } from "@/util/schema" +import { Effect, Stream } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" import path from "path" -import DESCRIPTION from "./bash.txt" import * as Log from "@opencode-ai/core/util/log" import { containsPath, type InstanceContext } from "../project/instance-context" +import { InstanceState } from "@/effect/instance-state" import { lazy } from "@/util/lazy" import { Language, type Node } from "web-tree-sitter" @@ -14,20 +13,21 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { fileURLToPath } from "url" import { Config } from "@/config/config" import { Flag } from "@opencode-ai/core/flag/flag" -import { Global } from "@opencode-ai/core/global" import { Shell } from "@/shell/shell" +import { ShellID } from "./shell/id" -import { BashArity } from "@/permission/arity" import * as Truncate from "./truncate" import { Plugin } from "@/plugin" -import { Effect, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" -import { InstanceState } from "@/effect/instance-state" +import { ShellPrompt, type Parameters } from "./shell/prompt" +import { BashArity } from "@/permission/arity" + +export { Parameters } from "./shell/prompt" const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 -const CWD = new Set(["cd", "push-location", "set-location"]) +const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"]) const FILES = new Set([ ...CWD, "rm", @@ -50,21 +50,10 @@ const FILES = new Set([ "new-item", "rename-item", ]) +const CMD_FILES = new Set(["copy", "del", "dir", "erase", "md", "mkdir", "move", "rd", "ren", "rename", "rmdir", "type"]) const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) -export const Parameters = Schema.Struct({ - command: Schema.String.annotate({ description: "The command to execute" }), - timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), - workdir: Schema.optional(Schema.String).annotate({ - description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, - }), - description: Schema.String.annotate({ - description: - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", - }), -}) - type Part = { type: string text: string @@ -81,7 +70,7 @@ type Chunk = { size: number } -export const log = Log.create({ service: "bash-tool" }) +export const log = Log.create({ service: "shell-tool" }) const resolveWasm = (asset: string) => { if (asset.startsWith("file://")) return fileURLToPath(asset) @@ -187,11 +176,16 @@ function prefix(text: string) { return text.slice(0, match.index) } -function pathArgs(list: Part[], ps: boolean) { +function pathArgs(list: Part[], ps: boolean, cmd = false) { if (!ps) { return list .slice(1) - .filter((item) => !item.text.startsWith("-") && !(list[0]?.text === "chmod" && item.text.startsWith("+"))) + .filter( + (item) => + !item.text.startsWith("-") && + !(cmd && item.text.startsWith("/")) && + !(list[0]?.text === "chmod" && item.text.startsWith("+")), + ) .map((item) => item.text) } @@ -251,13 +245,13 @@ function tail(text: string, maxLines: number, maxBytes: number) { } } -const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) { +const parse = Effect.fn("ShellTool.parse")(function* (command: string, ps: boolean) { const tree = yield* Effect.promise(() => parser().then((p) => (ps ? p.ps : p.bash).parse(command))) if (!tree) throw new Error("Failed to parse command") return tree }) -const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { +const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan) { if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) @@ -273,7 +267,7 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) if (scan.patterns.size === 0) return yield* ctx.ask({ - permission: "bash", + permission: ShellID.ToolID, patterns: Array.from(scan.patterns), always: Array.from(scan.always), metadata: {}, @@ -325,9 +319,8 @@ const parser = lazy(async () => { return { bash, ps } }) -// TODO: we may wanna rename this tool so it works better on other shells -export const BashTool = Tool.define( - "bash", +export const ShellTool = Tool.define( + ShellID.ToolID, Effect.gen(function* () { const config = yield* Config.Service const spawner = yield* ChildProcessSpawner @@ -335,7 +328,7 @@ export const BashTool = Tool.define( const trunc = yield* Truncate.Service const plugin = yield* Plugin.Service - const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) { + const cygpath = Effect.fn("ShellTool.cygpath")(function* (shell: string, text: string) { const lines = yield* spawner .lines(ChildProcess.make(shell, ["-lc", 'cygpath -w -- "$1"', "_", text])) .pipe(Effect.catch(() => Effect.succeed([] as string[]))) @@ -344,7 +337,7 @@ export const BashTool = Tool.define( return AppFileSystem.normalizePath(file) }) - const resolvePath = Effect.fn("BashTool.resolvePath")(function* (text: string, root: string, shell: string) { + const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) { if (process.platform === "win32") { if (Shell.posix(shell) && text.startsWith("/") && AppFileSystem.windowsPath(text) === text) { const file = yield* cygpath(shell, text) @@ -355,7 +348,7 @@ export const BashTool = Tool.define( return path.resolve(root, text) }) - const argPath = Effect.fn("BashTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { + const argPath = Effect.fn("ShellTool.argPath")(function* (arg: string, cwd: string, ps: boolean, shell: string) { const text = ps ? expand(arg, cwd, shell) : home(unquote(arg)) const file = text && prefix(text) if (!file || dynamic(file, ps)) return @@ -364,7 +357,7 @@ export const BashTool = Tool.define( return yield* resolvePath(next, cwd, shell) }) - const collect = Effect.fn("BashTool.collect")(function* ( + const collect = Effect.fn("ShellTool.collect")(function* ( root: Node, cwd: string, ps: boolean, @@ -376,14 +369,15 @@ export const BashTool = Tool.define( patterns: new Set(), always: new Set(), } + const shellKind = ShellID.toKind(Shell.name(shell)) for (const node of commands(root)) { const command = parts(node) const tokens = command.map((item) => item.text) - const cmd = ps ? tokens[0]?.toLowerCase() : tokens[0] + const cmd = ps || shellKind === "cmd" ? tokens[0]?.toLowerCase() : tokens[0] - if (cmd && FILES.has(cmd)) { - for (const arg of pathArgs(command, ps)) { + if (cmd && (FILES.has(cmd) || (shellKind === "cmd" && CMD_FILES.has(cmd)))) { + for (const arg of pathArgs(command, ps, shellKind === "cmd")) { const resolved = yield* argPath(arg, cwd, ps, shell) log.info("resolved path", { arg, resolved }) if (!resolved || containsPath(resolved, instance)) continue @@ -401,7 +395,7 @@ export const BashTool = Tool.define( return scan }) - const shellEnv = Effect.fn("BashTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { + const shellEnv = Effect.fn("ShellTool.shellEnv")(function* (ctx: Tool.Context, cwd: string) { const extra = yield* plugin.trigger( "shell.env", { cwd, sessionID: ctx.sessionID, callID: ctx.callID }, @@ -413,7 +407,7 @@ export const BashTool = Tool.define( } }) - const run = Effect.fn("BashTool.run")(function* ( + const run = Effect.fn("ShellTool.run")(function* ( input: { shell: string command: string @@ -527,7 +521,7 @@ export const BashTool = Tool.define( const meta: string[] = [] if (expired) { meta.push( - `bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, + `shell tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`, ) } if (aborted) meta.push("User aborted the command") @@ -546,7 +540,7 @@ export const BashTool = Tool.define( } if (meta.length > 0) { - output += "\n\n\n" + meta.join("\n") + "\n" + output += "\n\n\n" + meta.join("\n") + "\n" } if (sink) { const stream = sink @@ -577,25 +571,14 @@ export const BashTool = Tool.define( const cfg = yield* config.get() const shell = Shell.acceptable(cfg.shell) const name = Shell.name(shell) - const chain = - name === "powershell" - ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." - : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." - log.info("bash tool using shell", { shell }) - const limits = yield* trunc.limits() - const instance = yield* InstanceState.context + const prompt = ShellPrompt.render(name, process.platform, limits) + log.info("shell tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", instance.directory) - .replaceAll("${tmp}", Global.Path.tmp) - .replaceAll("${os}", process.platform) - .replaceAll("${shell}", name) - .replaceAll("${chaining}", chain) - .replaceAll("${maxLines}", String(limits.maxLines)) - .replaceAll("${maxBytes}", String(limits.maxBytes)), - parameters: Parameters, - execute: (params: Schema.Schema.Type, ctx: Tool.Context) => + description: prompt.description, + parameters: prompt.parameters, + execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const executeInstance = yield* InstanceState.context const cwd = params.workdir diff --git a/packages/opencode/src/tool/shell/id.ts b/packages/opencode/src/tool/shell/id.ts new file mode 100644 index 0000000000..061253f8fb --- /dev/null +++ b/packages/opencode/src/tool/shell/id.ts @@ -0,0 +1,19 @@ +const kinds = ["bash", "pwsh", "powershell", "cmd"] as const +export type Kind = (typeof kinds)[number] + +const shellKinds = new Set(kinds) + +function isKind(value: string): value is Kind { + return shellKinds.has(value) +} + +export function toKind(value: string): Kind { + return isKind(value) ? value : "bash" +} + +// Keep the exposed tool ID and permission key as "bash" for compatibility with +// existing plugins, users, and saved permissions. Rename with opencode 2.0. +export const ToolID = "bash" +export type ToolID = typeof ToolID + +export * as ShellID from "./id" diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts new file mode 100644 index 0000000000..77d0f4b5ed --- /dev/null +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -0,0 +1,297 @@ +import { Schema } from "effect" +import DESCRIPTION from "./shell.txt" +import { PositiveInt } from "@/util/schema" +import { Global } from "@opencode-ai/core/global" +import { ShellID } from "./id" + +const PS = new Set(["powershell", "pwsh"]) +const CMD = new Set(["cmd"]) + +const descriptions = { + bash: + "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + powershell: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', + cmd: + 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', +} + +export type Limits = { + maxLines: number + maxBytes: number +} + +export function parameterSchema(description: string) { + return Schema.Struct({ + command: Schema.String.annotate({ description: "The command to execute" }), + timeout: Schema.optional(PositiveInt).annotate({ description: "Optional timeout in milliseconds" }), + workdir: Schema.optional(Schema.String).annotate({ + description: `The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`, + }), + description: Schema.String.annotate({ description }), + }) +} + +export const Parameters = parameterSchema(descriptions.bash) +export type Parameters = Schema.Schema.Type + +function renderPrompt(template: string, values: Record) { + return template.replace(/\$\{(\w+)\}/g, (_, key: string) => { + const value = values[key] + if (value === undefined) throw new Error(`Missing shell prompt value: ${key}`) + return value + }) +} + +function shellDisplayName(name: string) { + if (name === "pwsh") return "PowerShell (7+)" + if (name === "powershell") return "Windows PowerShell (5.1)" + if (name === "cmd") return "cmd.exe" + return name +} + +function powershellNotes(name: string) { + if (name === "pwsh") { + return `# PowerShell (7+) shell notes +- This cross-platform shell supports pipeline chain operators (\`&&\` and \`||\`). +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + if (name === "powershell") { + return `# Windows PowerShell (5.1) shell notes +- Use \`cmd1; if ($?) { cmd2 }\` to chain dependent commands. +- Use double quotes for interpolated strings (\`"Hello $name"\`), single quotes for verbatim strings. +- Prefer full cmdlet names like \`Get-ChildItem\`, \`Set-Content\`, \`Remove-Item\`, and \`New-Item\` over aliases. +- Use \`$(...)\` for subexpressions. Use \`@(...)\` for array expressions. +- To call a native executable whose path contains spaces, use the call operator: \`& "path/to/exe" args\`. +- Escape special characters with the PowerShell backtick character.` + } + return "" +} + +function chainGuidance(name: string) { + if (name === "powershell") { + return "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell (5.1) does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." + } + if (PS.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single bash tool call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like New-Item before Copy-Item, Write before bash for git operations, or git add before git commit), run these operations sequentially instead." + } + if (CMD.has(name)) { + return "If the commands depend on each other and must run sequentially, use a single bash tool call with `&&` to chain them together (e.g., `mkdir out && dir out`). For instance, if one operation must complete before another starts, run these operations sequentially instead." + } + return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." +} + +function bashCommandSection(chain: string, limits: Limits) { + return `Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`ls\` to verify the parent directory exists and is the correct location + - For example, before running "mkdir foo/bar", first use \`ls foo\` to check that "foo" exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") + - Examples of proper quoting: + - mkdir "/Users/name/My Documents" (correct) + - mkdir /Users/name/My Documents (incorrect - will fail) + - python "/path/with spaces/script.py" (correct) + - python /path/with spaces/script.py (incorrect - will fail) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT find or ls) + - Content search: Use Grep (NOT grep or rg) + - Read files: Use Read (NOT cat/head/tail) + - Edit files: Use Edit (NOT sed/awk) + - Write files: Use Write (NOT echo >/cat < && \`. Use the \`workdir\` parameter to change directories instead. + + Use workdir="/foo/bar" with command: pytest tests + + + cd /foo/bar && pytest tests + ` +} + +function powershellCommandSection(name: string, chain: string, pathSep: string, limits: Limits) { + return `${powershellNotes(name)} + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`Test-Path -LiteralPath \` to verify the parent directory exists and is the correct location + - For example, before creating \`foo${pathSep}bar\`, first use \`Test-Path -LiteralPath "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., Remove-Item -LiteralPath "path with spaces${pathSep}file.txt") + - Examples of proper quoting: + - New-Item -ItemType Directory -Path "My Documents" (correct) + - New-Item -ItemType Directory -Path My Documents (incorrect - path is split) + - & "path with spaces${pathSep}script.ps1" (correct) + - path with spaces${pathSep}script.ps1 (incorrect - path is split and not invoked) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT Get-ChildItem) + - Content search: Use Grep (NOT Select-String) + - Read files: Use Read (NOT Get-Content) + - Edit files: Use Edit (NOT Set-Content) + - Write files: Use Write (NOT Set-Content/Out-File or here-strings) + - Communication: Output text directly (NOT Write-Output/Write-Host) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "git status" and "git diff", send a single message with two bash tool calls in parallel. + - ${chain} + - Use \`;\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project${pathSep}subdir" with command: pytest tests + + + ${name === "powershell" ? `Set-Location -LiteralPath "project${pathSep}subdir"; if ($?) { pytest tests }` : `Set-Location -LiteralPath "project${pathSep}subdir" && pytest tests`} + ` +} + +function cmdCommandSection(chain: string, limits: Limits) { + return `# cmd.exe shell notes +- Use double quotes for paths with spaces. +- Use %VAR% for environment variables. +- Use \`if exist\` for existence checks. +- Use \`call\` when invoking batch files from another batch-style command. + +Before executing the command, please follow these steps: + +1. Directory Verification: + - If the command will create new directories or files, first use \`if exist\` to verify the parent directory exists and is the correct location + - For example, before creating \`foo\\bar\`, first use \`if exist "foo\\" dir "foo"\` to check that \`foo\` exists and is the intended parent directory + +2. Command Execution: + - Always quote file paths that contain spaces with double quotes (e.g., del "path with spaces\\file.txt") + - Examples of proper quoting: + - mkdir "My Documents" (correct) + - mkdir My Documents (incorrect - path is split) + - call "path with spaces\\script.bat" (correct) + - path with spaces\\script.bat (incorrect - path is split and not invoked correctly) + - After ensuring proper quoting, execute the command. + - Capture the output of the command. + +Usage notes: + - The command argument is required. + - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). + - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. + - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching. + + - Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: + - File search: Use Glob (NOT dir /s) + - Content search: Use Grep (NOT findstr) + - Read files: Use Read (NOT type) + - Edit files: Use Edit (NOT copy) + - Write files: Use Write (NOT echo > file) + - Communication: Output text directly (NOT echo) + - When issuing multiple commands: + - If the commands are independent and can run in parallel, make multiple bash tool calls in a single message. For example, if you need to run "dir" and "where cmd", send a single message with two bash tool calls in parallel. + - ${chain} + - Use \`&\` only when you need to run commands sequentially but don't care if earlier commands fail + - DO NOT use newlines to separate commands (newlines are ok in quoted strings) + - AVOID changing directories inside the command. Use the \`workdir\` parameter to change directories instead. + + Use workdir="project\\subdir" with command: dir + + + cd /d "project\\subdir" && dir + ` +} + +function profile(name: string, platform: NodeJS.Platform, limits: Limits) { + const isPowerShell = PS.has(name) + const chain = chainGuidance(name) + if (CMD.has(name)) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: cmdCommandSection(chain, limits), + gitCommands: "git commands", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using a temporary body file so cmd.exe quoting stays simple.", + createPrExample: `(\n echo ## Summary\n echo - ^<1-3 bullet points^>\n) > pr-body.txt\ngh pr create --title "the pr title" --body-file pr-body.txt`, + parameterDescription: descriptions.cmd, + } + } + if (isPowerShell) { + return { + intro: `Executes a given ${shellDisplayName(name)} command with optional timeout, ensuring proper handling and security measures.`, + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID changing directories inside the command - use `workdir` instead.", + commandSection: powershellCommandSection(name, chain, platform === "win32" ? "\\" : "/", limits), + gitCommands: "git commands", + gitCommandRestriction: "git commands", + createPrInstruction: "Create PR using gh pr create with a PowerShell here-string to pass the body correctly.", + createPrExample: `gh pr create --title "the pr title" --body @' +## Summary +- <1-3 bullet points> +'@`, + parameterDescription: descriptions.powershell, + } + } + return { + intro: + "Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.", + workdirSection: + "All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead.", + commandSection: bashCommandSection(chain, limits), + gitCommands: "bash commands", + gitCommandRestriction: "git bash commands", + createPrInstruction: + "Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.", + createPrExample: `gh pr create --title "the pr title" --body "$(cat <<'EOF' +## Summary +<1-3 bullet points>`, + parameterDescription: descriptions.bash, + } +} + +export function render(name: string, platform: NodeJS.Platform, limits: Limits) { + const selected = profile(name, platform, limits) + return { + description: renderPrompt(DESCRIPTION, { + intro: selected.intro, + os: platform, + shell: name, + tmp: Global.Path.tmp, + workdirSection: selected.workdirSection, + commandSection: selected.commandSection, + gitCommands: selected.gitCommands, + toolName: ShellID.ToolID, + gitCommandRestriction: selected.gitCommandRestriction, + createPrInstruction: selected.createPrInstruction, + createPrExample: selected.createPrExample, + }), + parameters: parameterSchema(selected.parameterDescription), + } +} + +export * as ShellPrompt from "./prompt" diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/shell/shell.txt similarity index 59% rename from packages/opencode/src/tool/bash.txt rename to packages/opencode/src/tool/shell/shell.txt index a131ed7e63..5cba07805c 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/shell/shell.txt @@ -1,54 +1,14 @@ -Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures. +${intro} Be aware: OS: ${os}, Shell: ${shell} -All commands run in the current working directory by default. Use the `workdir` parameter if you need to run a command in a different directory. AVOID using `cd && ` patterns - use `workdir` instead. +${workdirSection} Use `${tmp}` for temporary work outside the workspace. This directory has already been created, already exists, and is pre-approved for external directory access. IMPORTANT: This tool is for terminal operations like git, npm, docker, etc. DO NOT use it for file operations (reading, writing, editing, searching, finding files) - use the specialized tools for this instead. -Before executing the command, please follow these steps: - -1. Directory Verification: - - If the command will create new directories or files, first use `ls` to verify the parent directory exists and is the correct location - - For example, before running "mkdir foo/bar", first use `ls foo` to check that "foo" exists and is the intended parent directory - -2. Command Execution: - - Always quote file paths that contain spaces with double quotes (e.g., rm "path with spaces/file.txt") - - Examples of proper quoting: - - mkdir "/Users/name/My Documents" (correct) - - mkdir /Users/name/My Documents (incorrect - will fail) - - python "/path/with spaces/script.py" (correct) - - python /path/with spaces/script.py (incorrect - will fail) - - After ensuring proper quoting, execute the command. - - Capture the output of the command. - -Usage notes: - - The command argument is required. - - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - - It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - - If the output exceeds ${maxLines} lines or ${maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use `head`, `tail`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching. - - - Avoid using Bash with the `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands: - - File search: Use Glob (NOT find or ls) - - Content search: Use Grep (NOT grep or rg) - - Read files: Use Read (NOT cat/head/tail) - - Edit files: Use Edit (NOT sed/awk) - - Write files: Use Write (NOT echo >/cat < && `. Use the `workdir` parameter to change directories instead. - - Use workdir="/foo/bar" with command: pytest tests - - - cd /foo/bar && pytest tests - +${commandSection} # Committing changes with git @@ -67,7 +27,7 @@ Git Safety Protocol: - CRITICAL: If you already pushed to remote, NEVER amend unless user explicitly requests it (requires force push) - 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. -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel, each using the Bash tool: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel, each using the ${toolName} tool: - Run a git status command to see all untracked files. - Run a git diff command to see both staged and unstaged changes that will be committed. - Run a git log command to see recent commit messages, so that you can follow this repository's commit message style. @@ -84,18 +44,18 @@ Git Safety Protocol: 4. If the commit fails due to pre-commit hook, fix the issue and create a NEW commit (see amend rules above) Important notes: -- NEVER run additional commands to read or explore code, besides git bash commands +- NEVER run additional commands to read or explore code, besides ${gitCommandRestriction} - NEVER use the TodoWrite or Task tools - DO NOT push to the remote repository unless the user explicitly asks you to do so - IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported. - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit # Creating pull requests -Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. +Use the gh command via the ${toolName} tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: -1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following bash commands in parallel using the Bash tool, in order to understand the current state of the branch since it diverged from the main branch: +1. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following ${gitCommands} in parallel using the ${toolName} tool, in order to understand the current state of the branch since it diverged from the main branch: - Run a git status command to see all untracked files - Run a git diff command to see both staged and unstaged changes that will be committed - Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote @@ -104,11 +64,9 @@ IMPORTANT: When the user asks you to create a pull request, follow these steps c 3. You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. run the following commands in parallel: - Create new branch if needed - Push to remote with -u flag if needed - - Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting. + - ${createPrInstruction} -gh pr create --title "the pr title" --body "$(cat <<'EOF' -## Summary -<1-3 bullet points> +${createPrExample} Important: diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index afd24e7e1b..a7853be0b8 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -620,7 +620,7 @@ describe("session.message-v2.toModelMessage", () => { status: "completed", input: { cmd: "ls" }, output: "abcdefghij", - title: "Bash", + title: "Shell", metadata: {}, time: { start: 0, end: 1 }, }, @@ -740,9 +740,9 @@ describe("session.message-v2.toModelMessage", () => { "12179", "4575", "", - "", + "", "User aborted the command", - "", + "", ].join("\n") const input: MessageV2.WithParts[] = [ diff --git a/packages/opencode/test/tool/parameters.test.ts b/packages/opencode/test/tool/parameters.test.ts index bc42b0324b..9f6a0617ed 100644 --- a/packages/opencode/test/tool/parameters.test.ts +++ b/packages/opencode/test/tool/parameters.test.ts @@ -10,7 +10,6 @@ import { toJsonSchema } from "../../src/util/effect-zod" // byte-identical regardless of whether a tool has migrated from zod to Schema. import { Parameters as ApplyPatch } from "../../src/tool/apply_patch" -import { Parameters as Bash } from "../../src/tool/bash" import { Parameters as Edit } from "../../src/tool/edit" import { Parameters as Glob } from "../../src/tool/glob" import { Parameters as Grep } from "../../src/tool/grep" @@ -19,6 +18,7 @@ import { Parameters as Lsp } from "../../src/tool/lsp" import { Parameters as Plan } from "../../src/tool/plan" import { Parameters as Question } from "../../src/tool/question" import { Parameters as Read } from "../../src/tool/read" +import { Parameters as Shell } from "../../src/tool/shell" import { Parameters as Skill } from "../../src/tool/skill" import { Parameters as Task } from "../../src/tool/task" import { Parameters as Todo } from "../../src/tool/todo" @@ -35,7 +35,7 @@ const accepts = (schema: Schema.Decoder, input: unknown): boolean => describe("tool parameters", () => { describe("JSON Schema (wire shape)", () => { test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot()) - test("bash", () => expect(toJsonSchema(Bash)).toMatchSnapshot()) + test("bash", () => expect(toJsonSchema(Shell)).toMatchSnapshot()) test("edit", () => expect(toJsonSchema(Edit)).toMatchSnapshot()) test("glob", () => expect(toJsonSchema(Glob)).toMatchSnapshot()) test("grep", () => expect(toJsonSchema(Grep)).toMatchSnapshot()) @@ -66,20 +66,20 @@ describe("tool parameters", () => { }) }) - describe("bash", () => { + describe("shell", () => { test("accepts minimum: command + description", () => { - expect(parse(Bash, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) + expect(parse(Shell, { command: "ls", description: "list" })).toEqual({ command: "ls", description: "list" }) }) test("accepts optional timeout + workdir", () => { - const parsed = parse(Bash, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) + const parsed = parse(Shell, { command: "ls", description: "list", timeout: 5000, workdir: "/tmp" }) expect(parsed.timeout).toBe(5000) expect(parsed.workdir).toBe("/tmp") }) - test("rejects missing description (required by zod)", () => { - expect(accepts(Bash, { command: "ls" })).toBe(false) + test("rejects missing description", () => { + expect(accepts(Shell, { command: "ls" })).toBe(false) }) test("rejects missing command", () => { - expect(accepts(Bash, { description: "list" })).toBe(false) + expect(accepts(Shell, { description: "list" })).toBe(false) }) }) diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/shell.test.ts similarity index 92% rename from packages/opencode/test/tool/bash.test.ts rename to packages/opencode/test/tool/shell.test.ts index 513cfa18ea..43295e2d5d 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -4,7 +4,7 @@ import os from "os" import path from "path" import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" -import { BashTool } from "../../src/tool/bash" +import { ShellTool } from "../../src/tool/shell" import { Instance } from "../../src/project/instance" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -28,9 +28,11 @@ const runtime = ManagedRuntime.make( ) function initBash() { - return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init()))) + return runtime.runPromise(ShellTool.pipe(Effect.flatMap((info) => info.init()))) } +const initShell = initBash + const ctx = { sessionID: SessionID.make("ses_test"), messageID: MessageID.make(""), @@ -68,6 +70,7 @@ const shells = (() => { })() const PS = new Set(["pwsh", "powershell"]) const ps = shells.filter((item) => PS.has(item.label)) +const cmdShell = shells.find((item) => item.label === "cmd") const sh = () => Shell.name(Shell.acceptable()) const evalarg = (text: string) => (sh() === "cmd" ? quote(text) : squote(text)) @@ -135,12 +138,12 @@ const mustTruncate = (result: { ) } -describe("tool.bash", () => { +describe("tool.shell", () => { each("basic", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -184,13 +187,13 @@ describe("tool.bash", () => { }) }) -describe("tool.bash permissions", () => { +describe("tool.shell permissions", () => { each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir() await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -213,7 +216,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -239,7 +242,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -261,11 +264,43 @@ describe("tool.bash permissions", () => { ) } + for (const item of ps) { + test( + `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, + withShell(item, async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await initShell() + const err = new Error("stop after permission") + const requests: Array> = [] + await expect( + Effect.runPromise( + bash.execute( + { + command: "Remove-Item -Recurse tmp", + description: "Remove a temp directory", + }, + capture(requests, err), + ), + ), + ).rejects.toThrow(err.message) + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.always).toContain("Remove-Item *") + expect(bashReq!.always).not.toContain("Remove-Item -Recurse *") + }, + }) + }), + ) + } + each("asks for external_directory permission for wildcard external paths", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] const file = process.platform === "win32" ? `${process.env.WINDIR!.replaceAll("\\", "/")}/*` : "/etc/*" @@ -301,7 +336,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const file = path.join(outerTmp.path, "outside.txt").replaceAll("\\", "/") const requests: Array> = [] await Effect.runPromise( @@ -334,7 +369,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -364,7 +399,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] const file = `${process.env.WINDIR!.replaceAll("\\", "/")}/win.ini` await Effect.runPromise( @@ -396,7 +431,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -426,7 +461,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -521,7 +556,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] const root = path.parse(process.env.WINDIR!).root.replace(/[\\/]+$/, "") @@ -680,7 +715,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -702,6 +737,35 @@ describe("tool.bash permissions", () => { } } + if (process.platform === "win32" && cmdShell) { + test( + "asks for external_directory permission for cmd file commands [cmd]", + withShell(cmdShell, async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await initShell() + const requests: Array> = [] + await Effect.runPromise( + bash.execute( + { + command: `TYPE "${path.join(process.env.WINDIR!, "win.ini")}"`, + description: "Read Windows ini with cmd", + }, + capture(requests), + ), + ) + const extDirReq = requests.find((r) => r.permission === "external_directory") + expect(extDirReq).toBeDefined() + expect(extDirReq!.patterns).toContain( + Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), + ) + }, + }) + }), + ) + } + each("asks for external_directory permission when cd to parent", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -945,7 +1009,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const requests: Array> = [] await Effect.runPromise( bash.execute( @@ -967,7 +1031,7 @@ describe("tool.bash permissions", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const bash = await initBash() + const bash = await initShell() const err = new Error("stop after permission") const requests: Array> = [] await expect( @@ -1001,12 +1065,12 @@ describe("tool.bash permissions", () => { }) }) -describe("tool.bash abort", () => { +describe("tool.shell abort", () => { test("preserves output when aborted", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const controller = new AbortController() const collected: string[] = [] const res = await Effect.runPromise( @@ -1040,7 +1104,7 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1052,7 +1116,7 @@ describe("tool.bash abort", () => { ), ) expect(result.output).toContain("started") - expect(result.output).toContain("bash tool terminated command after exceeding timeout") + expect(result.output).toContain("shell tool terminated command after exceeding timeout") expect(result.output).toContain("retry with a larger timeout value in milliseconds") }, }) @@ -1062,7 +1126,7 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1083,7 +1147,7 @@ describe("tool.bash abort", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1128,12 +1192,12 @@ describe("tool.bash abort", () => { }) }) -describe("tool.bash truncation", () => { +describe("tool.shell truncation", () => { test("truncates output exceeding line limit", async () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const lineCount = Truncate.MAX_LINES + 500 const result = await Effect.runPromise( bash.execute( @@ -1155,7 +1219,7 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const byteCount = Truncate.MAX_BYTES + 10000 const result = await Effect.runPromise( bash.execute( @@ -1177,7 +1241,7 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const result = await Effect.runPromise( bash.execute( { @@ -1197,7 +1261,7 @@ describe("tool.bash truncation", () => { await Instance.provide({ directory: projectRoot, fn: async () => { - const bash = await initBash() + const bash = await initShell() const lineCount = Truncate.MAX_LINES + 100 const result = await Effect.runPromise( bash.execute( From 7ab1c1c74a93162b3ca6fb60b2739be7a029365d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 19:19:06 -0400 Subject: [PATCH 052/178] refactor(cli): convert debug agent command to effectCmd (#25485) --- packages/opencode/src/cli/cmd/debug/agent.ts | 189 +++++++++---------- 1 file changed, 89 insertions(+), 100 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index cff9a7f9cc..831ca08b69 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -7,14 +7,14 @@ import { Session } from "@/session/session" import type { MessageV2 } from "../../../session/message-v2" import { MessageID, PartID } from "../../../session/schema" import { ToolRegistry } from "@/tool/registry" -import { Instance } from "../../../project/instance" import { Permission } from "../../../permission" import { iife } from "../../../util/iife" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd, fail } from "../../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" +import type { InstanceContext } from "@/project/instance" -export const AgentCommand = cmd({ +export const AgentCommand = effectCmd({ command: "agent ", describe: "show agent configuration details", builder: (yargs) => @@ -32,60 +32,61 @@ export const AgentCommand = cmd({ type: "string", description: "Tool params as JSON or a JS object literal", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const agentName = args.name as string - const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName))) - if (!agent) { - process.stderr.write( - `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL, - ) - process.exit(1) - } - const availableTools = await getAvailableTools(agent) - const resolvedTools = await resolveTools(agent, availableTools) - const toolID = args.tool as string | undefined - if (toolID) { - const tool = availableTools.find((item) => item.id === toolID) - if (!tool) { - process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL) - process.exit(1) - } - if (resolvedTools[toolID] === false) { - process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL) - process.exit(1) - } - const params = parseToolParams(args.params as string | undefined) - const ctx = await createToolContext(agent) - const result = await tool.execute(params, ctx) - process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL) - return - } + handler: Effect.fn("Cli.debug.agent")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* run(args, ctx).pipe(Effect.ensuring(store.dispose(ctx))) + }), +}) - const output = { - ...agent, - tools: resolvedTools, - } - process.stdout.write(JSON.stringify(output, null, 2) + EOL) - }) - }, +const run = Effect.fn("Cli.debug.agent.body")(function* ( + args: { name: string; tool?: string; params?: string }, + ctx: InstanceContext, +) { + const agentName = args.name + const agent = yield* Agent.Service.use((svc) => svc.get(agentName)) + if (!agent) { + process.stderr.write( + `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL, + ) + return yield* fail("", 1) + } + const availableTools = yield* getAvailableTools(agent) + const resolvedTools = resolveTools(agent, availableTools) + const toolID = args.tool + if (toolID) { + const tool = availableTools.find((item) => item.id === toolID) + if (!tool) { + process.stderr.write(`Tool ${toolID} not found for agent ${agentName}` + EOL) + return yield* fail("", 1) + } + if (resolvedTools[toolID] === false) { + process.stderr.write(`Tool ${toolID} is disabled for agent ${agentName}` + EOL) + return yield* fail("", 1) + } + const params = parseToolParams(args.params) + const toolCtx = yield* createToolContext(agent, ctx) + const result = yield* tool.execute(params, toolCtx) + process.stdout.write(JSON.stringify({ tool: toolID, input: params, result }, null, 2) + EOL) + return + } + + const output = { + ...agent, + tools: resolvedTools, + } + process.stdout.write(JSON.stringify(output, null, 2) + EOL) }) -async function getAvailableTools(agent: Agent.Info) { - return AppRuntime.runPromise( - Effect.gen(function* () { - const provider = yield* Provider.Service - const registry = yield* ToolRegistry.Service - const model = agent.model ?? (yield* provider.defaultModel()) - return yield* registry.tools({ - ...model, - agent, - }) - }), - ) -} +const getAvailableTools = Effect.fn("Cli.debug.agent.getAvailableTools")(function* (agent: Agent.Info) { + const provider = yield* Provider.Service + const registry = yield* ToolRegistry.Service + const model = agent.model ?? (yield* provider.defaultModel()) + return yield* registry.tools({ ...model, agent }) +}) -async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { +function resolveTools(agent: Agent.Info, availableTools: { id: string }[]) { const disabled = Permission.disabled( availableTools.map((tool) => tool.id), agent.permission, @@ -123,50 +124,38 @@ function parseToolParams(input?: string) { return parsed as Record } -async function createToolContext(agent: Agent.Info) { - const { session, messageID } = await AppRuntime.runPromise( - Effect.gen(function* () { - const session = yield* Session.Service - const result = yield* session.create({ title: `Debug tool run (${agent.name})` }) - const messageID = MessageID.ascending() - const model = agent.model - ? agent.model - : yield* Effect.gen(function* () { - const provider = yield* Provider.Service - return yield* provider.defaultModel() - }) - const now = Date.now() - const message: MessageV2.Assistant = { - id: messageID, - sessionID: result.id, - role: "assistant", - time: { - created: now, - }, - parentID: messageID, - modelID: model.modelID, - providerID: model.providerID, - mode: "debug", - agent: agent.name, - path: { - cwd: Instance.directory, - root: Instance.worktree, - }, - cost: 0, - tokens: { - input: 0, - output: 0, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - } - yield* session.updateMessage(message) - return { session: result, messageID } - }), - ) +const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(function* ( + agent: Agent.Info, + ctx: InstanceContext, +) { + const sessionSvc = yield* Session.Service + const session = yield* sessionSvc.create({ title: `Debug tool run (${agent.name})` }) + const messageID = MessageID.ascending() + const model = agent.model + ? agent.model + : yield* Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.defaultModel() + }) + const now = Date.now() + const message: MessageV2.Assistant = { + id: messageID, + sessionID: session.id, + role: "assistant", + time: { created: now }, + parentID: messageID, + modelID: model.modelID, + providerID: model.providerID, + mode: "debug", + agent: agent.name, + path: { + cwd: ctx.directory, + root: ctx.worktree, + }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } + yield* sessionSvc.updateMessage(message) const ruleset = Permission.merge(agent.permission, session.permission ?? []) @@ -189,4 +178,4 @@ async function createToolContext(agent: Agent.Info) { }) }, } -} +}) From 9d03d4419e4fada267dc522c0ae55b488ab85d3b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 23:20:15 +0000 Subject: [PATCH 053/178] chore: generate --- packages/opencode/src/tool/shell.ts | 15 ++++++++++++++- packages/opencode/src/tool/shell/prompt.ts | 6 ++---- packages/opencode/test/tool/shell.test.ts | 4 +--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index bb2e4e58df..d3ca542684 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -50,7 +50,20 @@ const FILES = new Set([ "new-item", "rename-item", ]) -const CMD_FILES = new Set(["copy", "del", "dir", "erase", "md", "mkdir", "move", "rd", "ren", "rename", "rmdir", "type"]) +const CMD_FILES = new Set([ + "copy", + "del", + "dir", + "erase", + "md", + "mkdir", + "move", + "rd", + "ren", + "rename", + "rmdir", + "type", +]) const FLAGS = new Set(["-destination", "-literalpath", "-path"]) const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"]) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index 77d0f4b5ed..45c637863a 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -8,12 +8,10 @@ const PS = new Set(["powershell", "pwsh"]) const CMD = new Set(["cmd"]) const descriptions = { - bash: - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + bash: "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", powershell: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: Get-ChildItem -LiteralPath "."\nOutput: Lists current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: New-Item -ItemType Directory -Path "tmp"\nOutput: Creates directory tmp', - cmd: - 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', + cmd: 'Clear, concise description of what this command does in 5-10 words. Examples:\nInput: dir\nOutput: Lists current directory\n\nInput: if exist "package.json" type "package.json"\nOutput: Prints package.json when it exists\n\nInput: mkdir tmp\nOutput: Creates directory tmp', } export type Limits = { diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 43295e2d5d..e68d16ba81 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -757,9 +757,7 @@ describe("tool.shell permissions", () => { ) const extDirReq = requests.find((r) => r.permission === "external_directory") expect(extDirReq).toBeDefined() - expect(extDirReq!.patterns).toContain( - Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*")), - ) + expect(extDirReq!.patterns).toContain(Filesystem.normalizePathPattern(path.join(process.env.WINDIR!, "*"))) }, }) }), From 4de44bbbefec557e519b9ac99bc39482957df99e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 19:22:51 -0400 Subject: [PATCH 054/178] refactor(cli): convert debug subcommands to effectCmd (#25479) --- packages/opencode/src/cli/cmd/debug/config.ts | 22 ++--- packages/opencode/src/cli/cmd/debug/file.ts | 81 +++++++++++-------- packages/opencode/src/cli/cmd/debug/lsp.ts | 60 ++++++++------ .../opencode/src/cli/cmd/debug/ripgrep.ts | 80 +++++++++--------- packages/opencode/src/cli/cmd/debug/skill.ts | 27 +++---- .../opencode/src/cli/cmd/debug/snapshot.ts | 54 ++++++++----- 6 files changed, 185 insertions(+), 139 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/config.ts b/packages/opencode/src/cli/cmd/debug/config.ts index a80b6a5819..8102fcfb88 100644 --- a/packages/opencode/src/cli/cmd/debug/config.ts +++ b/packages/opencode/src/cli/cmd/debug/config.ts @@ -1,17 +1,21 @@ import { EOL } from "os" +import { Effect } from "effect" import { Config } from "@/config/config" -import { AppRuntime } from "@/effect/app-runtime" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" +import { effectCmd } from "../../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" -export const ConfigCommand = cmd({ +export const ConfigCommand = effectCmd({ command: "config", describe: "show resolved configuration", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + handler: Effect.fn("Cli.debug.config")(function* () { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const config = yield* Config.Service.use((cfg) => cfg.get()) process.stdout.write(JSON.stringify(config, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 8e4eaa4e4d..1e2eb13bb7 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,11 +1,13 @@ import { EOL } from "os" -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import { File } from "../../../file" import { Ripgrep } from "@/file/ripgrep" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" -const FileSearchCommand = cmd({ +const FileSearchCommand = effectCmd({ command: "search ", describe: "search files by query", builder: (yargs) => @@ -14,15 +16,18 @@ const FileSearchCommand = cmd({ demandOption: true, description: "Search query", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const results = await AppRuntime.runPromise(File.Service.use((svc) => svc.search({ query: args.query }))) + handler: Effect.fn("Cli.debug.file.search")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const results = yield* File.Service.use((svc) => svc.search({ query: args.query })) process.stdout.write(results.join(EOL) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const FileReadCommand = cmd({ +const FileReadCommand = effectCmd({ command: "read ", describe: "read file contents as JSON", builder: (yargs) => @@ -31,27 +36,33 @@ const FileReadCommand = cmd({ demandOption: true, description: "File path to read", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const content = await AppRuntime.runPromise(File.Service.use((svc) => svc.read(args.path))) + handler: Effect.fn("Cli.debug.file.read")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const content = yield* File.Service.use((svc) => svc.read(args.path)) process.stdout.write(JSON.stringify(content, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const FileStatusCommand = cmd({ +const FileStatusCommand = effectCmd({ command: "status", describe: "show file status information", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const status = await AppRuntime.runPromise(File.Service.use((svc) => svc.status())) + handler: Effect.fn("Cli.debug.file.status")(function* () { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const status = yield* File.Service.use((svc) => svc.status()) process.stdout.write(JSON.stringify(status, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const FileListCommand = cmd({ +const FileListCommand = effectCmd({ command: "list ", describe: "list files in a directory", builder: (yargs) => @@ -60,15 +71,18 @@ const FileListCommand = cmd({ demandOption: true, description: "File path to list", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const files = await AppRuntime.runPromise(File.Service.use((svc) => svc.list(args.path))) + handler: Effect.fn("Cli.debug.file.list")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const files = yield* File.Service.use((svc) => svc.list(args.path)) process.stdout.write(JSON.stringify(files, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const FileTreeCommand = cmd({ +const FileTreeCommand = effectCmd({ command: "tree [dir]", describe: "show directory tree", builder: (yargs) => @@ -77,12 +91,15 @@ const FileTreeCommand = cmd({ description: "Directory to tree", default: process.cwd(), }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const tree = await AppRuntime.runPromise(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) + handler: Effect.fn("Cli.debug.file.tree")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) console.log(JSON.stringify(tree, null, 2)) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) export const FileCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 6312afcf18..b822a98bc1 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -1,10 +1,11 @@ import { LSP } from "@/lsp/lsp" -import { AppRuntime } from "../../../effect/app-runtime" import { Effect } from "effect" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import * as Log from "@opencode-ai/core/util/log" import { EOL } from "os" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" export const LSPCommand = cmd({ command: "lsp", @@ -14,47 +15,54 @@ export const LSPCommand = cmd({ async handler() {}, }) -const DiagnosticsCommand = cmd({ +const DiagnosticsCommand = effectCmd({ command: "diagnostics ", describe: "get diagnostics for a file", builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const out = await AppRuntime.runPromise( - LSP.Service.use((lsp) => - Effect.gen(function* () { - yield* lsp.touchFile(args.file, "full") - return yield* lsp.diagnostics() - }), - ), + handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const out = yield* LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.touchFile(args.file, "full") + return yield* lsp.diagnostics() + }), ) process.stdout.write(JSON.stringify(out, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -export const SymbolsCommand = cmd({ +export const SymbolsCommand = effectCmd({ command: "symbols ", describe: "search workspace symbols", builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { + handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { using _ = Log.Default.time("symbols") - const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))) + const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -export const DocumentSymbolsCommand = cmd({ +export const DocumentSymbolsCommand = effectCmd({ command: "document-symbols ", describe: "get symbols from a document", builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }), - async handler(args) { - await bootstrap(process.cwd(), async () => { + handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { using _ = Log.Default.time("document-symbols") - const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))) + const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 9b7e826915..73c7ada2b1 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -1,10 +1,10 @@ import { EOL } from "os" import { Effect, Stream } from "effect" -import { AppRuntime } from "../../../effect/app-runtime" import { Ripgrep } from "../../../file/ripgrep" -import { Instance } from "../../../project/instance" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" export const RipgrepCommand = cmd({ command: "rg", @@ -13,24 +13,25 @@ export const RipgrepCommand = cmd({ async handler() {}, }) -const TreeCommand = cmd({ +const TreeCommand = effectCmd({ command: "tree", describe: "show file tree using ripgrep", builder: (yargs) => yargs.option("limit", { type: "number", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const tree = await AppRuntime.runPromise( - Ripgrep.Service.use((svc) => svc.tree({ cwd: Instance.directory, limit: args.limit })), - ) + handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) process.stdout.write(tree + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const FilesCommand = cmd({ +const FilesCommand = effectCmd({ command: "files", describe: "list files using ripgrep", builder: (yargs) => @@ -47,29 +48,29 @@ const FilesCommand = cmd({ type: "number", description: "Limit number of results", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const files = await AppRuntime.runPromise( - Effect.gen(function* () { - const rg = yield* Ripgrep.Service - return yield* rg - .files({ - cwd: Instance.directory, - glob: args.glob ? [args.glob] : undefined, - }) - .pipe( - Stream.take(args.limit ?? Infinity), - Stream.runCollect, - Effect.map((c) => [...c]), - ) - }), - ) + handler: Effect.fn("Cli.debug.rg.files")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const rg = yield* Ripgrep.Service + const files = yield* rg + .files({ + cwd: ctx.directory, + glob: args.glob ? [args.glob] : undefined, + }) + .pipe( + Stream.take(args.limit ?? Infinity), + Stream.runCollect, + Effect.map((c) => [...c]), + Effect.orDie, + ) process.stdout.write(files.join(EOL) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const SearchCommand = cmd({ +const SearchCommand = effectCmd({ command: "search ", describe: "search file contents using ripgrep", builder: (yargs) => @@ -87,12 +88,15 @@ const SearchCommand = cmd({ type: "number", description: "Limit number of results", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - const results = await AppRuntime.runPromise( + handler: Effect.fn("Cli.debug.rg.search")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const results = yield* Effect.orDie( Ripgrep.Service.use((svc) => svc.search({ - cwd: Instance.directory, + cwd: ctx.directory, pattern: args.pattern, glob: args.glob as string[] | undefined, limit: args.limit, @@ -100,6 +104,6 @@ const SearchCommand = cmd({ ), ) process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/skill.ts b/packages/opencode/src/cli/cmd/debug/skill.ts index 79179411b6..e23410a69b 100644 --- a/packages/opencode/src/cli/cmd/debug/skill.ts +++ b/packages/opencode/src/cli/cmd/debug/skill.ts @@ -1,23 +1,22 @@ import { EOL } from "os" import { Effect } from "effect" -import { AppRuntime } from "@/effect/app-runtime" import { Skill } from "../../../skill" -import { bootstrap } from "../../bootstrap" -import { cmd } from "../cmd" +import { effectCmd } from "../../effect-cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" -export const SkillCommand = cmd({ +export const SkillCommand = effectCmd({ command: "skill", describe: "list all available skills", builder: (yargs) => yargs, - async handler() { - await bootstrap(process.cwd(), async () => { - const skills = await AppRuntime.runPromise( - Effect.gen(function* () { - const skill = yield* Skill.Service - return yield* skill.all() - }), - ) + handler: Effect.fn("Cli.debug.skill")(function* () { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const skill = yield* Skill.Service + const skills = yield* skill.all() process.stdout.write(JSON.stringify(skills, null, 2) + EOL) - }) - }, + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) diff --git a/packages/opencode/src/cli/cmd/debug/snapshot.ts b/packages/opencode/src/cli/cmd/debug/snapshot.ts index 6663398a45..1675f175df 100644 --- a/packages/opencode/src/cli/cmd/debug/snapshot.ts +++ b/packages/opencode/src/cli/cmd/debug/snapshot.ts @@ -1,7 +1,9 @@ -import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import { Snapshot } from "../../../snapshot" -import { bootstrap } from "../../bootstrap" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" +import { InstanceRef } from "@/effect/instance-ref" +import { InstanceStore } from "@/project/instance-store" export const SnapshotCommand = cmd({ command: "snapshot", @@ -10,17 +12,21 @@ export const SnapshotCommand = cmd({ async handler() {}, }) -const TrackCommand = cmd({ +const TrackCommand = effectCmd({ command: "track", describe: "track current snapshot state", - async handler() { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.track()))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.track")(function* () { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const out = yield* Snapshot.Service.use((svc) => svc.track()) + console.log(out) + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const PatchCommand = cmd({ +const PatchCommand = effectCmd({ command: "patch ", describe: "show patch for a snapshot hash", builder: (yargs) => @@ -29,14 +35,18 @@ const PatchCommand = cmd({ description: "hash", demandOption: true, }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.patch(args.hash)))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash)) + console.log(out) + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) -const DiffCommand = cmd({ +const DiffCommand = effectCmd({ command: "diff ", describe: "show diff for a snapshot hash", builder: (yargs) => @@ -45,9 +55,13 @@ const DiffCommand = cmd({ description: "hash", demandOption: true, }), - async handler(args) { - await bootstrap(process.cwd(), async () => { - console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.diff(args.hash)))) - }) - }, + handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return + const store = yield* InstanceStore.Service + return yield* Effect.gen(function* () { + const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash)) + console.log(out) + }).pipe(Effect.ensuring(store.dispose(ctx))) + }), }) From 36007aecf429603f8a2e823106cff02baffa2bc3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 23:23:53 +0000 Subject: [PATCH 055/178] chore: generate --- packages/opencode/src/cli/cmd/debug/ripgrep.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index 73c7ada2b1..f0be704485 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -25,7 +25,9 @@ const TreeCommand = effectCmd({ if (!ctx) return const store = yield* InstanceStore.Service return yield* Effect.gen(function* () { - const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) + const tree = yield* Effect.orDie( + Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })), + ) process.stdout.write(tree + EOL) }).pipe(Effect.ensuring(store.dispose(ctx))) }), From f98053c34e5ca56901818f72aeee84536a6187a5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 19:33:38 -0400 Subject: [PATCH 056/178] fix(instance): run bootstrap from instance store (#25475) --- packages/opencode/src/cli/bootstrap.ts | 6 +- packages/opencode/src/cli/cmd/tui/worker.ts | 17 ++-- packages/opencode/src/config/config.ts | 36 ++------ packages/opencode/src/effect/app-runtime.ts | 20 +---- .../opencode/src/project/bootstrap-service.ts | 9 ++ packages/opencode/src/project/bootstrap.ts | 14 ++- .../opencode/src/project/instance-runtime.ts | 27 ++++++ .../opencode/src/project/instance-store.ts | 15 +--- packages/opencode/src/project/instance.ts | 6 +- .../opencode/src/server/global-lifecycle.ts | 37 ++++++++ packages/opencode/src/server/routes/global.ts | 24 +++--- .../src/server/routes/instance/config.ts | 5 +- .../routes/instance/httpapi/groups/global.ts | 1 + .../instance/httpapi/handlers/config.ts | 2 +- .../instance/httpapi/handlers/global.ts | 15 ++-- .../httpapi/middleware/instance-context.ts | 10 +-- .../server/routes/instance/httpapi/server.ts | 6 +- .../src/server/routes/instance/index.ts | 5 +- .../src/server/routes/instance/middleware.ts | 2 - .../src/server/routes/instance/project.ts | 10 +-- packages/opencode/src/server/workspace.ts | 4 +- .../agent/plugin-agent-regression.test.ts | 51 +++++++++++ packages/opencode/test/config/config.test.ts | 53 +++++++----- packages/opencode/test/config/tui.test.ts | 11 ++- .../test/effect/instance-state.test.ts | 7 +- packages/opencode/test/fixture/config.ts | 23 +++++ packages/opencode/test/fixture/fixture.ts | 47 +++++++--- packages/opencode/test/mcp/lifecycle.test.ts | 4 +- .../opencode/test/permission/next.test.ts | 16 ++-- .../test/plugin/auth-override.test.ts | 73 +++++++++++----- .../test/plugin/loader-shared.test.ts | 40 ++++++--- .../instance-bootstrap-regression.test.ts | 85 +++++++++++++++++++ .../opencode/test/project/instance.test.ts | 7 +- .../opencode/test/project/worktree.test.ts | 16 ++-- .../opencode/test/question/question.test.ts | 6 +- .../server/httpapi-instance-context.test.ts | 6 +- .../opencode/test/server/httpapi-mcp.test.ts | 4 +- .../test/server/httpapi-provider.test.ts | 4 +- .../opencode/test/session/compaction.test.ts | 3 +- .../opencode/test/session/instruction.test.ts | 17 +--- packages/opencode/test/tool/registry.test.ts | 42 ++++++++- .../opencode/test/tool/truncation.test.ts | 3 +- 42 files changed, 540 insertions(+), 249 deletions(-) create mode 100644 packages/opencode/src/project/bootstrap-service.ts create mode 100644 packages/opencode/src/project/instance-runtime.ts create mode 100644 packages/opencode/src/server/global-lifecycle.ts create mode 100644 packages/opencode/test/agent/plugin-agent-regression.test.ts create mode 100644 packages/opencode/test/fixture/config.ts create mode 100644 packages/opencode/test/project/instance-bootstrap-regression.test.ts diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index da90ec4033..81a085d689 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,17 +1,15 @@ import { Instance } from "../project/instance" -import { InstanceStore } from "../project/instance-store" -import { getBootstrapRunEffect } from "../effect/app-runtime" +import { InstanceRuntime } from "../project/instance-runtime" export async function bootstrap(directory: string, cb: () => Promise) { return Instance.provide({ directory, - init: await getBootstrapRunEffect(), fn: async () => { try { const result = await cb() return result } finally { - await InstanceStore.disposeInstance(Instance.current) + await InstanceRuntime.disposeInstance(Instance.current) } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index dd6f7e246d..e4fbeb2fbc 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -2,7 +2,7 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "@/project/instance" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" @@ -10,8 +10,10 @@ import { GlobalBus } from "@/bus/global" import { Flag } from "@opencode-ai/core/flag/flag" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" -import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime" +import { AppRuntime } from "@/effect/app-runtime" import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" +import { Effect } from "effect" +import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" ensureProcessMetadata("worker") @@ -77,19 +79,24 @@ export const rpc = { async checkUpgrade(input: { directory: string }) { await Instance.provide({ directory: input.directory, - init: await getBootstrapRunEffect(), fn: async () => { await upgrade().catch(() => {}) }, }) }, async reload() { - await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true))) + await AppRuntime.runPromise( + Effect.gen(function* () { + const cfg = yield* Config.Service + yield* cfg.invalidate() + yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }) + }), + ) }, async shutdown() { Log.Default.info("worker shutting down") - await InstanceStore.disposeAllInstances() + await InstanceRuntime.disposeAllInstances() if (server) await server.stop(true) }, } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 46a31cf1c4..a63d77013f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -12,11 +12,8 @@ import { Auth } from "../auth" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" import { type InstanceContext } from "../project/instance" -import { InstanceStore } from "../project/instance-store" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { existsSync } from "fs" -import { GlobalBus } from "@/bus/global" -import { Event } from "../server/event" import { Account } from "@/account/account" import { isRecord } from "@/util/record" import type { ConsoleState } from "./console-state" @@ -289,9 +286,9 @@ export interface Interface { readonly get: () => Effect.Effect readonly getGlobal: () => Effect.Effect readonly getConsoleState: () => Effect.Effect - readonly update: (config: Info, options?: { dispose?: boolean }) => Effect.Effect - readonly updateGlobal: (config: Info) => Effect.Effect - readonly invalidate: (wait?: boolean) => Effect.Effect + readonly update: (config: Info) => Effect.Effect + readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }> + readonly invalidate: () => Effect.Effect readonly directories: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect } @@ -730,37 +727,17 @@ export const layer = Layer.effect( ) }) - const update = Effect.fn("Config.update")(function* (config: Info, options?: { dispose?: boolean }) { + const update = Effect.fn("Config.update")(function* (config: Info) { const dir = yield* InstanceState.directory const file = path.join(dir, "config.json") const existing = yield* loadFile(file) yield* fs .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) .pipe(Effect.orDie) - if (options?.dispose !== false) { - // Fail loudly if no instance is bound — silently skipping would - // mask "config update without an active instance" bugs. The throw - // comes from `Instance.current` inside `InstanceState.context`. - const ctx = yield* InstanceState.context - yield* Effect.promise(() => InstanceStore.disposeInstance(ctx)) - } }) - const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) { + const invalidate = Effect.fn("Config.invalidate")(function* () { yield* invalidateGlobal - const task = InstanceStore.disposeAllInstances() - .catch(() => undefined) - .finally(() => - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Event.Disposed.type, - properties: {}, - }, - }), - ) - if (wait) yield* Effect.promise(() => task) - else void task }) const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { @@ -784,9 +761,8 @@ export const layer = Layer.effect( if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } - // Only tear down running instances if the config actually changed. if (changed) yield* invalidate() - return next + return { info: next, changed } }) return Service.of({ diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 66f3a9b378..901738646c 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -1,4 +1,4 @@ -import { Effect, Layer, ManagedRuntime } from "effect" +import { Layer, ManagedRuntime } from "effect" import { attach } from "./run-service" import * as Observability from "@opencode-ai/core/effect/observability" @@ -40,8 +40,7 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" -import { InstanceBootstrap } from "@/project/bootstrap" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" @@ -94,8 +93,7 @@ export const AppLayer = Layer.mergeAll( Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, - InstanceBootstrap.defaultLayer, - InstanceStore.defaultLayer, + InstanceRuntime.layer, Project.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, @@ -132,15 +130,3 @@ export const AppRuntime: Runtime = { }, dispose: () => rt.dispose(), } - -let bootstrapRun: Promise> -export function getBootstrapRunEffect(): Promise> { - if (!bootstrapRun) { - bootstrapRun = AppRuntime.runPromise( - Effect.gen(function* () { - return (yield* InstanceBootstrap.Service).run - }), - ) - } - return bootstrapRun -} diff --git a/packages/opencode/src/project/bootstrap-service.ts b/packages/opencode/src/project/bootstrap-service.ts new file mode 100644 index 0000000000..b20cc54cd6 --- /dev/null +++ b/packages/opencode/src/project/bootstrap-service.ts @@ -0,0 +1,9 @@ +import { Context, Effect } from "effect" + +export interface Interface { + readonly run: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} + +export * as InstanceBootstrap from "./bootstrap-service" diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 9f77de2d4d..ea2aa2e848 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -10,21 +10,19 @@ import { Command } from "../command" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" -import { Context, Effect, Layer } from "effect" +import { Effect, Layer } from "effect" import { Config } from "@/config/config" +import { Service } from "./bootstrap-service" -export interface Interface { - readonly run: Effect.Effect -} - -export class Service extends Context.Service()("@opencode/InstanceBootstrap") {} +export { Service } from "./bootstrap-service" +export type { Interface } from "./bootstrap-service" export const layer = Layer.effect( Service, Effect.gen(function* () { // Yield each bootstrap dep at layer init so `run` itself has R = never. - // This breaks the circular declaration loop through Config → Instance → InstanceStore - // (instance-store.ts only yields this Service tag, never the impl-side services). + // InstanceStore imports only the lightweight tag from bootstrap-service.ts, + // so it can depend on bootstrap without importing this implementation graph. const bus = yield* Bus.Service const config = yield* Config.Service const file = yield* File.Service diff --git a/packages/opencode/src/project/instance-runtime.ts b/packages/opencode/src/project/instance-runtime.ts new file mode 100644 index 0000000000..a30bf56107 --- /dev/null +++ b/packages/opencode/src/project/instance-runtime.ts @@ -0,0 +1,27 @@ +import { makeRuntime } from "@/effect/run-service" +import { type InstanceContext } from "./instance-context" +import { InstanceStore, type LoadInput } from "./instance-store" +import { Effect, Layer } from "effect" + +// Production InstanceStore wiring plus a bridge for Promise/ALS callers that +// cannot yet yield InstanceStore.Service. This keeps InstanceStore itself +// low-level while still giving legacy Hono and CLI paths the production +// bootstrap implementation. Delete the Promise helpers once those callers are +// migrated to Effect boundaries that provide InstanceStore directly. +// Keep the bootstrap implementation import lazy: Instance is imported broadly, +// and importing the app bootstrap graph at module load can trigger ESM cycles. +export const layer = Layer.unwrap( + Effect.promise(async () => { + const { InstanceBootstrap } = await import("./bootstrap") + return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) + }), +) + +const runtime = makeRuntime(InstanceStore.Service, layer) + +export const load = (input: LoadInput) => runtime.runPromise((store) => store.load(input)) +export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx)) +export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll()) +export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input)) + +export * as InstanceRuntime from "./instance-runtime" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 00075be64b..41adcbc7cf 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -2,10 +2,10 @@ import { GlobalBus } from "@/bus/global" import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceRef } from "@/effect/instance-ref" import { disposeInstance as runDisposers } from "@/effect/instance-registry" -import { makeRuntime } from "@/effect/run-service" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" import { type InstanceContext } from "./instance-context" +import { InstanceBootstrap } from "./bootstrap-service" import * as Project from "./project" export interface LoadInput { @@ -36,10 +36,11 @@ interface Entry { readonly deferred: Deferred.Deferred } -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const project = yield* Project.Service + const bootstrap = yield* InstanceBootstrap.Service const scope = yield* Scope.Scope const cache = new Map() @@ -59,6 +60,7 @@ export const layer: Layer.Layer = Layer.effect( project: result.project, })), ) + yield* bootstrap.run.pipe(Effect.provideService(InstanceRef, ctx)) if (input.init) yield* input.init.pipe(Effect.provideService(InstanceRef, ctx)) return ctx }).pipe(Effect.withSpan("InstanceStore.boot")) @@ -195,13 +197,4 @@ export const layer: Layer.Layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(Project.defaultLayer)) -export const runtime = makeRuntime(Service, defaultLayer) - -// Promise-returning helpers for callers without an Effect runtime in scope. -// They route through `runtime` (not a yielded Service from a fresh runtime) -// so they share the cache that `Instance.provide` populates. -export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx)) -export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll()) -export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input)) - export * as InstanceStore from "./instance-store" diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 5b2bcf6b32..81977affc3 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,15 +1,13 @@ import { Effect } from "effect" import { context, type InstanceContext } from "./instance-context" -import { InstanceStore } from "./instance-store" +import { InstanceRuntime } from "./instance-runtime" export type { InstanceContext } from "./instance-context" export type { LoadInput } from "./instance-store" export const Instance = { async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { - const ctx = await InstanceStore.runtime.runPromise((store) => - store.load({ directory: input.directory, init: input.init }), - ) + const ctx = await InstanceRuntime.load({ directory: input.directory, init: input.init }) return context.provide(ctx, async () => input.fn()) }, get current() { diff --git a/packages/opencode/src/server/global-lifecycle.ts b/packages/opencode/src/server/global-lifecycle.ts new file mode 100644 index 0000000000..fbc300fad7 --- /dev/null +++ b/packages/opencode/src/server/global-lifecycle.ts @@ -0,0 +1,37 @@ +import { GlobalBus } from "@/bus/global" +import { InstanceStore } from "@/project/instance-store" +import * as Log from "@opencode-ai/core/util/log" +import { Effect } from "effect" +import { Event } from "./event" + +const log = Log.create({ service: "server" }) + +export const emitGlobalDisposed = Effect.sync(() => + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }), +) + +export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn( + "Server.disposeAllInstancesAndEmitGlobalDisposed", +)(function* (options?: { swallowErrors?: boolean }) { + const store = yield* InstanceStore.Service + yield* Effect.gen(function* () { + yield* (options?.swallowErrors + ? store.disposeAll().pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("global disposal failed", { cause }) + }), + ), + ) + : store.disposeAll()) + yield* emitGlobalDisposed + }).pipe(Effect.uninterruptible) +}) + +export * as GlobalLifecycle from "./global-lifecycle" diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index f40a584536..4a491d95b6 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -1,25 +1,23 @@ import { Hono, type Context } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import { streamSSE } from "hono/streaming" -import { Effect, Schema } from "effect" +import { Effect } from "effect" import z from "zod" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" import { GlobalBus } from "@/bus/global" import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" -import { InstanceStore } from "../../project/instance-store" import { Installation } from "@/installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { lazy } from "../../util/lazy" import { Config } from "@/config/config" import { errors } from "../error" +import { disposeAllInstancesAndEmitGlobalDisposed } from "../global-lifecycle" const log = Log.create({ service: "server" }) -export const GlobalDisposedEvent = BusEvent.define("global.disposed", Schema.Struct({})) - async function streamEvents(c: Context, subscribe: (q: AsyncQueue) => () => void) { return streamSSE(c, async (stream) => { const q = new AsyncQueue() @@ -178,8 +176,13 @@ export const GlobalRoutes = lazy(() => validator("json", Config.Info.zod), async (c) => { const config = c.req.valid("json") - const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) - return c.json(next) + const result = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) + if (result.changed) { + void AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })).catch( + () => undefined, + ) + } + return c.json(result.info) }, ) .post( @@ -200,14 +203,7 @@ export const GlobalRoutes = lazy(() => }, }), async (c) => { - await InstanceStore.disposeAllInstances() - GlobalBus.emit("event", { - directory: "global", - payload: { - type: GlobalDisposedEvent.type, - properties: {}, - }, - }) + await AppRuntime.runPromise(disposeAllInstancesAndEmitGlobalDisposed()) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index f055917b0c..96a7e756de 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -1,7 +1,8 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" -import z from "zod" import { Config } from "@/config/config" +import { InstanceState } from "@/effect/instance-state" +import { InstanceStore } from "@/project/instance-store" import { Provider } from "@/provider/provider" import { errors } from "../../error" import { lazy } from "@/util/lazy" @@ -55,7 +56,9 @@ export const ConfigRoutes = lazy(() => jsonRequest("ConfigRoutes.update", c, function* () { const config = c.req.valid("json") const cfg = yield* Config.Service + const store = yield* InstanceStore.Service yield* cfg.update(config) + yield* store.dispose(yield* InstanceState.context) return config }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts index 272b086065..75441b4ca4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/global.ts @@ -1,6 +1,7 @@ import { Config } from "@/config/config" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" +import "@/server/event" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 58aa81098c..753ba03138 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -16,7 +16,7 @@ export const configHandlers = HttpApiBuilder.group(InstanceHttpApi, "config", (h }) const update = Effect.fn("ConfigHttpApi.update")(function* (ctx) { - yield* configSvc.update(ctx.payload, { dispose: false }) + yield* configSvc.update(ctx.payload) yield* markInstanceForDisposal(yield* InstanceState.context) return ctx.payload }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index bcad2832e2..f9be57f4fd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,7 +1,8 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" +import { EffectBridge } from "@/effect/bridge" import { Installation } from "@/installation" -import { InstanceStore } from "@/project/instance-store" +import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as Log from "@opencode-ai/core/util/log" import { Effect, Queue, Schema } from "effect" @@ -68,7 +69,7 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl Effect.gen(function* () { const config = yield* Config.Service const installation = yield* Installation.Service - const store = yield* InstanceStore.Service + const bridge = yield* EffectBridge.make() const health = Effect.fn("GlobalHttpApi.health")(function* () { return { healthy: true as const, version: InstallationVersion } @@ -83,15 +84,13 @@ export const globalHandlers = HttpApiBuilder.group(RootHttpApi, "global", (handl }) const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) { - return yield* config.updateGlobal(ctx.payload) + const result = yield* config.updateGlobal(ctx.payload) + if (result.changed) bridge.fork(disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true })) + return result.info }) const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () { - yield* store.disposeAll() - GlobalBus.emit("event", { - directory: "global", - payload: { type: "global.disposed", properties: {} }, - }) + yield* disposeAllInstancesAndEmitGlobalDisposed() return true }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts index 0e82da31b3..d4913696d2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts @@ -1,5 +1,4 @@ import { WorkspaceRef } from "@/effect/instance-ref" -import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -24,12 +23,11 @@ function decode(input: string): string { function provideInstanceContext( effect: Effect.Effect, store: InstanceStore.Interface, - bootstrap: InstanceBootstrap.Interface, ): Effect.Effect { return Effect.gen(function* () { const route = yield* WorkspaceRouteContext return yield* store.provide( - { directory: decode(route.directory), init: bootstrap.run }, + { directory: decode(route.directory) }, effect.pipe(Effect.provideService(WorkspaceRef, route.workspaceID)), ) }) @@ -39,15 +37,13 @@ export const instanceContextLayer = Layer.effect( InstanceContextMiddleware, Effect.gen(function* () { const store = yield* InstanceStore.Service - const bootstrap = yield* InstanceBootstrap.Service - return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store, bootstrap)) + return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store)) }), ) export const instanceRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { const store = yield* InstanceStore.Service - const bootstrap = yield* InstanceBootstrap.Service - return (effect) => provideInstanceContext(effect, store, bootstrap) + return (effect) => provideInstanceContext(effect, store) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 767bfc31db..ce1b213729 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -18,8 +18,7 @@ import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" -import { InstanceBootstrap } from "@/project/bootstrap" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" @@ -153,8 +152,7 @@ export function createRoutes(corsOptions?: CorsOptions) { Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, - InstanceBootstrap.defaultLayer, - InstanceStore.defaultLayer, + InstanceRuntime.layer, MCP.defaultLayer, ModelsDev.defaultLayer, Permission.defaultLayer, diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 530c02345a..f0da2f3d85 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -6,7 +6,7 @@ import z from "zod" import { Format } from "@/format" import { TuiRoutes } from "./tui" import { Instance } from "@/project/instance" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Vcs } from "@/project/vcs" import { Agent } from "@/agent/agent" import { Skill } from "@/skill" @@ -25,7 +25,6 @@ import { ExperimentalRoutes } from "./experimental" import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" -import { InstanceMiddleware } from "./middleware" import { jsonRequest } from "./trace" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { @@ -63,7 +62,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { }, }), async (c) => { - await InstanceStore.disposeInstance(Instance.current) + await InstanceRuntime.disposeInstance(Instance.current) return c.json(true) }, ) diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index db7b9b52f9..494459500d 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,6 +1,5 @@ import type { MiddlewareHandler } from "hono" import { Instance } from "@/project/instance" -import { getBootstrapRunEffect } from "@/effect/app-runtime" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceID } from "@/control-plane/schema" @@ -23,7 +22,6 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler async fn() { return Instance.provide({ directory, - init: await getBootstrapRunEffect(), async fn() { return next() }, diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 01a45c2fb9..3d8bb605bd 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -2,13 +2,12 @@ import { Hono } from "hono" import { describeRoute, validator } from "hono-openapi" import { resolver } from "hono-openapi" import { Instance } from "@/project/instance" -import { InstanceStore } from "@/project/instance-store" +import { InstanceRuntime } from "@/project/instance-runtime" import { Project } from "@/project/project" import z from "zod" import { ProjectID } from "@/project/schema" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { getBootstrapRunEffect } from "@/effect/app-runtime" import { jsonRequest, runRequest } from "./trace" export const ProjectRoutes = lazy(() => @@ -82,12 +81,7 @@ export const ProjectRoutes = lazy(() => Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })), ) if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) - await InstanceStore.reloadInstance({ - directory: dir, - worktree: dir, - project: next, - init: await getBootstrapRunEffect(), - }) + await InstanceRuntime.reloadInstance({ directory: dir, worktree: dir, project: next }) return c.json(next) }, ) diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 0036c9ab46..dbf693e8fc 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -5,7 +5,7 @@ import { WorkspaceID } from "@/control-plane/schema" import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" -import { getBootstrapRunEffect, AppRuntime } from "@/effect/app-runtime" +import { AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" @@ -94,13 +94,11 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware const target = await adapter.target(workspace) if (target.type === "local") { - const init = await getBootstrapRunEffect() return WorkspaceContext.provide({ workspaceID: WorkspaceID.make(workspaceID), fn: () => Instance.provide({ directory: target.directory, - init, async fn() { return next() }, diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts new file mode 100644 index 0000000000..89e8a66407 --- /dev/null +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -0,0 +1,51 @@ +import { afterEach, expect, test } from "bun:test" +import path from "path" +import { pathToFileURL } from "url" +import { AppRuntime } from "../../src/effect/app-runtime" +import { Agent } from "../../src/agent/agent" +import { Instance } from "../../src/project/instance" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() +}) + +test("plugin-registered agents appear in Agent.list", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginFile = path.join(dir, "plugin.ts") + await Bun.write( + pluginFile, + [ + "export default async () => ({", + " config: async (cfg) => {", + " cfg.agent = cfg.agent ?? {}", + " cfg.agent.plugin_added = {", + ' description: "Added by a plugin via the config hook",', + ' mode: "subagent",', + " }", + " },", + "})", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) + const added = agents.find((agent) => agent.name === "plugin_added") + expect(added?.description).toBe("Added by a plugin via the config hook") + expect(added?.mode).toBe("subagent") + }, + }) +}) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 5b2e91e374..9c4cbd788c 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -12,8 +12,9 @@ import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { provideTestInstance, provideTmpdirInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" +import { InstanceRuntime } from "@/project/instance-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" @@ -41,6 +42,12 @@ const emptyAuth = Layer.mock(Auth.Service)({ const testFlock = EffectFlock.defaultLayer +const noopNpm = Layer.mock(Npm.Service)({ + install: () => Effect.void, + add: () => Effect.die("not implemented"), + which: () => Effect.succeed(Option.none()), +}) + const layer = Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), @@ -48,7 +55,7 @@ const layer = Config.layer.pipe( Layer.provide(emptyAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) const it = testEffect(layer) @@ -57,9 +64,17 @@ const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe const save = (config: Config.Info) => Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer))) const saveGlobal = (config: Config.Info) => - Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer))) -const clear = (wait = false) => - Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer))) + Effect.runPromise( + Config.Service.use((svc) => svc.updateGlobal(config)).pipe( + Effect.map((result) => result.info), + Effect.scoped, + Effect.provide(layer), + ), + ) +const clear = async (wait = false) => { + await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer))) + if (wait) await InstanceRuntime.disposeAllInstances() +} const listDirs = () => Effect.runPromise(Config.Service.use((svc) => svc.directories()).pipe(Effect.scoped, Effect.provide(layer))) const ready = () => @@ -108,7 +123,7 @@ async function check(map: (dir: string) => string) { }, }) } finally { - await disposeAllInstances() + await InstanceRuntime.disposeAllInstances() ;(Global.Path as { config: string }).config = prev await clear() } @@ -483,6 +498,7 @@ test("resolves env templates in account config with account token", async () => Layer.provide(emptyAuth), Layer.provide(fakeAccount), Layer.provideMerge(infra), + Layer.provide(noopNpm), ) try { @@ -493,7 +509,7 @@ test("resolves env templates in account config with account token", async () => expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token") }), ), - ).pipe(Effect.scoped, Effect.provide(layer), Effect.provide(Npm.defaultLayer), Effect.runPromise) + ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) } finally { if (originalControlToken !== undefined) { process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken @@ -550,7 +566,7 @@ test("validates config schema and throws on invalid fields", async () => { }) }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { // Strict schema should throw an error for invalid fields @@ -565,7 +581,7 @@ test("throws error for invalid JSON", async () => { await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }") }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { await expect(load()).rejects.toThrow() @@ -986,11 +1002,6 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { const prev = process.env.OPENCODE_CONFIG_DIR process.env.OPENCODE_CONFIG_DIR = tmp.extra - const noopNpm = Layer.mock(Npm.Service)({ - install: () => Effect.void, - add: () => Effect.die("not implemented"), - which: () => Effect.succeed(Option.none()), - }) const testLayer = Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), @@ -1061,7 +1072,7 @@ test("resolves scoped npm plugins in config", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { const config = await load() @@ -1099,7 +1110,7 @@ test("merges plugin arrays from global and local configs", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1258,7 +1269,7 @@ test("deduplicates duplicate plugins from global and local configs", async () => }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1307,7 +1318,7 @@ test("keeps plugin origins aligned with merged plugin list", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const cfg = await load() @@ -1883,7 +1894,7 @@ test("project config overrides remote well-known config", async () => { Layer.provide(fakeAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) try { @@ -1941,7 +1952,7 @@ test("wellknown URL with trailing slash is normalized", async () => { Layer.provide(fakeAuth), Layer.provide(emptyAccount), Layer.provideMerge(infra), - Layer.provide(Npm.defaultLayer), + Layer.provide(noopNpm), ) try { @@ -2096,7 +2107,7 @@ describe("deduplicatePluginOrigins", () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 46a3f06263..a3f2a1b5fb 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { provideTestInstance, tmpdir } from "../fixture/fixture" +import { InstanceRuntime } from "@/project/instance-runtime" import { TuiConfig } from "../../src/cli/cmd/tui/config/tui" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" @@ -13,7 +13,10 @@ import { CurrentWorkingDirectory } from "@/cli/cmd/tui/config/cwd" import { ConfigPlugin } from "@/config/plugin" const wintest = process.platform === "win32" ? test : test.skip -const clear = (wait = false) => AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate(wait))) +const clear = async (wait = false) => { + await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate())) + if (wait) await InstanceRuntime.disposeAllInstances() +} const load = () => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())) beforeEach(async () => { @@ -87,7 +90,7 @@ test("keeps server and tui plugin merge semantics aligned", async () => { }, }) - await Instance.provide({ + await provideTestInstance({ directory: tmp.path, fn: async () => { const server = await load() diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 0a8972ca4a..f5e6933883 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -3,9 +3,8 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" -import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(CrossSpawnSpawner.defaultLayer) @@ -70,7 +69,7 @@ it.live("InstanceState invalidates on reload", () => ) const a = yield* access(state, dir) - yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const b = yield* access(state, dir) expect(a).not.toBe(b) @@ -270,7 +269,7 @@ it.live("InstanceState correct after interleaved init and dispose", () => const [, b] = yield* Effect.all( [ - Effect.promise(() => InstanceStore.reloadInstance({ directory: one })), + Effect.promise(() => reloadTestInstance({ directory: one })), Test.use((svc) => svc.get()).pipe(provideInstance(two)), ], { concurrency: "unbounded" }, diff --git a/packages/opencode/test/fixture/config.ts b/packages/opencode/test/fixture/config.ts new file mode 100644 index 0000000000..4cd90c51bf --- /dev/null +++ b/packages/opencode/test/fixture/config.ts @@ -0,0 +1,23 @@ +import { Config } from "@/config/config" +import { emptyConsoleState } from "@/config/console-state" +import { Effect, Layer } from "effect" + +export function make(overrides: Partial = {}) { + return Config.Service.of({ + get: () => Effect.succeed({}), + getGlobal: () => Effect.succeed({}), + getConsoleState: () => Effect.succeed(emptyConsoleState), + update: () => Effect.void, + updateGlobal: (config) => Effect.succeed({ info: config, changed: false }), + invalidate: () => Effect.void, + directories: () => Effect.succeed([]), + waitForDependencies: () => Effect.void, + ...overrides, + }) +} + +export function layer(overrides?: Partial) { + return Layer.succeed(Config.Service, make(overrides)) +} + +export * as TestConfig from "./config" diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 1b193e382a..38017e516c 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -1,20 +1,44 @@ import { $ } from "bun" +import * as Observability from "@opencode-ai/core/effect/observability" import * as fs from "fs/promises" import os from "os" import path from "path" -import { Effect, Context } from "effect" +import { Effect, Context, Layer, ManagedRuntime } from "effect" import type * as PlatformError from "effect/PlatformError" import type * as Scope from "effect/Scope" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { InstanceStore } from "../../src/project/instance-store" import { Instance } from "../../src/project/instance" import { TestLLMServer } from "../lib/llm-server" -// Re-export for test ergonomics. The implementation lives next to the runtime -// it consumes; see `InstanceStore.disposeAllInstances` for the rationale. -export { disposeAllInstances } from "../../src/project/instance-store" +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const testInstanceRuntime = ManagedRuntime.make( + InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap), Layer.provideMerge(Observability.layer)), +) + +const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect.Effect) => + testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) + +export async function provideTestInstance(input: { directory: string; init?: Effect.Effect; fn: () => R }) { + const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory, init: input.init })) + try { + return await Instance.restore(ctx, () => input.fn()) + } finally { + await runTestInstanceStore((store) => store.dispose(ctx)) + } +} + +export async function reloadTestInstance(input: { directory: string }) { + return runTestInstanceStore((store) => store.reload(input)) +} + +export async function disposeAllInstances() { + await Promise.all([InstanceRuntime.disposeAllInstances(), runTestInstanceStore((store) => store.disposeAll())]) +} // Strip null bytes from paths (defensive fix for CI environment issues) function sanitizePath(p: string): string { @@ -129,12 +153,10 @@ export const provideInstance = (directory: string) => (self: Effect.Effect): Effect.Effect => Effect.contextWith((services: Context.Context) => - Effect.promise(async () => - Instance.provide({ - directory, - fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))), - }), - ), + Effect.promise(async () => { + const ctx = await runTestInstanceStore((store) => store.load({ directory })) + return Instance.restore(ctx, () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx)))) + }), ) export function provideTmpdirInstance( @@ -148,10 +170,7 @@ export function provideTmpdirInstance( yield* Effect.addFinalizer(() => provided ? Effect.promise(() => - Instance.provide({ - directory: path, - fn: () => InstanceStore.disposeInstance(Instance.current), - }), + runTestInstanceStore((store) => store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx)))), ).pipe(Effect.ignore) : Effect.void, ) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 59fa54ceab..2ba487f3f5 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,5 +1,5 @@ import { test, expect, mock, beforeEach } from "bun:test" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Effect } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -198,7 +198,7 @@ function withInstance( fn: async () => { await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) // dispose instance to clean up state between tests - await InstanceStore.disposeInstance(Instance.current) + await InstanceRuntime.disposeInstance(Instance.current) }, }) } diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index c615e55e5e..4d66784d81 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,8 +6,14 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" -import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { + disposeAllInstances, + provideInstance, + provideTmpdirInstance, + reloadTestInstance, + tmpdirScoped, +} from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" @@ -1000,7 +1006,7 @@ it.live("pending permission rejects on instance dispose", () => expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) yield* Effect.promise(() => - Instance.provide({ directory: dir, fn: () => void InstanceStore.disposeInstance(Instance.current) }), + Instance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }), ) const exit = yield* Fiber.await(fiber) @@ -1024,7 +1030,7 @@ it.live("pending permission rejects on instance reload", () => }).pipe(run, Effect.forkScoped) expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) - yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) @@ -1118,7 +1124,7 @@ it.live("ask - abort should clear pending request", () => const pending = yield* waitForPending(1).pipe(run) expect(pending).toHaveLength(1) - yield* Effect.promise(() => InstanceStore.reloadInstance({ directory: dir })) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) const exit = yield* Fiber.await(fiber) expect(Exit.isFailure(exit)).toBe(true) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 4bee985796..c77c0ca1c0 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,11 +1,40 @@ import { describe, expect, test } from "bun:test" import path from "path" import fs from "fs/promises" -import { Effect } from "effect" -import { tmpdir } from "../fixture/fixture" -import { Instance } from "../../src/project/instance" +import { pathToFileURL } from "url" +import { Effect, Layer } from "effect" +import { provideTestInstance, tmpdir } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" import { ProviderID } from "../../src/provider/schema" +import { Plugin } from "@/plugin" +import { Auth } from "@/auth" +import { Bus } from "@/bus" +import { TestConfig } from "../fixture/config" + +function layer(directory: string, plugins: string[]) { + return ProviderAuth.layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide( + Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide( + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ + spec: plugin, + source: path.join(directory, "opencode.json"), + scope: "local" as const, + })), + }), + directories: () => Effect.succeed([directory]), + }), + ), + ), + ), + ) +} describe("plugin.auth-override", () => { test("user plugin overrides built-in github-copilot auth", async () => { @@ -37,30 +66,32 @@ describe("plugin.auth-override", () => { await using plain = await tmpdir() - const methods = await Instance.provide({ - directory: tmp.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)), - ) - }, - }) - - const plainMethods = await Instance.provide({ - directory: plain.path, - fn: async () => { - return Effect.runPromise( - ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(ProviderAuth.defaultLayer)), - ) - }, - }) + const plugin = pathToFileURL(path.join(tmp.path, ".opencode", "plugin", "custom-copilot-auth.ts")).href + const [methods, plainMethods] = await Promise.all([ + provideTestInstance({ + directory: tmp.path, + fn: async () => { + return Effect.runPromise( + ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(tmp.path, [plugin]))), + ) + }, + }), + provideTestInstance({ + directory: plain.path, + fn: async () => { + return Effect.runPromise( + ProviderAuth.Service.use((svc) => svc.methods()).pipe(Effect.provide(layer(plain.path, []))), + ) + }, + }), + ]) const copilot = methods[ProviderID.make("github-copilot")] expect(copilot).toBeDefined() expect(copilot.length).toBe(1) expect(copilot[0].label).toBe("Test Override Auth") expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") - }, 30000) // Increased timeout for plugin installation + }, 30000) }) const file = path.join(import.meta.dir, "../../src/plugin/index.ts") diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index e24cd05070..8c55950aff 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,9 +1,9 @@ import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" import { pathToFileURL } from "url" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Filesystem } from "@/util/filesystem" const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS @@ -12,8 +12,9 @@ process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") -const { Instance } = await import("../../src/project/instance") +const { Bus } = await import("../../src/bus") const { Npm } = await import("@opencode-ai/core/npm") +const { TestConfig } = await import("../fixture/config") afterAll(() => { if (disableDefault === undefined) { @@ -28,14 +29,31 @@ afterEach(async () => { }) async function load(dir: string) { - return Instance.provide({ - directory: dir, - fn: async () => - Effect.gen(function* () { - const plugin = yield* Plugin.Service - yield* plugin.list() - }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), - }) + const source = path.join(dir, "opencode.json") + const config = (await Bun.file(source).json()) as { plugin?: Array]> } + const plugins = config.plugin ?? [] + return Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.list() + }).pipe( + Effect.provide( + Plugin.layer.pipe( + Layer.provide(Bus.layer), + Layer.provide( + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ spec: plugin, source, scope: "local" as const })), + }), + directories: () => Effect.succeed([dir]), + }), + ), + ), + ), + provideInstance(dir), + Effect.runPromise, + ) } describe("plugin.loader.shared", () => { diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap-regression.test.ts new file mode 100644 index 0000000000..bb8d43e015 --- /dev/null +++ b/packages/opencode/test/project/instance-bootstrap-regression.test.ts @@ -0,0 +1,85 @@ +import { afterEach, expect, test } from "bun:test" +import { Hono } from "hono" +import { existsSync } from "node:fs" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" +import { Instance } from "../../src/project/instance" +import { InstanceRuntime } from "../../src/project/instance-runtime" +import { InstanceMiddleware } from "../../src/server/routes/instance/middleware" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +// These regressions cover the legacy instance-loading paths fixed by PRs +// #25389 and #25449. The plugin config hook writes a marker file, and the test +// bodies deliberately avoid touching Plugin or config directly. The marker only +// exists if InstanceBootstrap ran at the instance boundary. + +afterEach(async () => { + await disposeAllInstances() +}) + +async function bootstrapFixture() { + return tmpdir({ + init: async (dir) => { + const marker = path.join(dir, "config-hook-fired") + const pluginFile = path.join(dir, "plugin.ts") + await Bun.write( + pluginFile, + [ + `const MARKER = ${JSON.stringify(marker)}`, + "export default async () => ({", + " config: async () => {", + ' await Bun.write(MARKER, "ran")', + " },", + "})", + "", + ].join("\n"), + ) + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ) + return marker + }, + }) +} + +test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => { + await using tmp = await bootstrapFixture() + + await Instance.provide({ + directory: tmp.path, + fn: async () => "ok", + }) + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("CLI bootstrap runs InstanceBootstrap before callback", async () => { + await using tmp = await bootstrapFixture() + + await cliBootstrap(tmp.path, async () => "ok") + + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("legacy Hono instance middleware runs InstanceBootstrap before next handler", async () => { + await using tmp = await bootstrapFixture() + const app = new Hono().use(InstanceMiddleware()).get("/probe", (c) => c.text("ok")) + + const response = await app.request("/probe", { headers: { "x-opencode-directory": tmp.path } }) + + expect(response.status).toBe(200) + expect(existsSync(tmp.extra)).toBe(true) +}) + +test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => { + await using tmp = await bootstrapFixture() + + await InstanceRuntime.reloadInstance({ directory: tmp.path }) + + expect(existsSync(tmp.extra)).toBe(true) +}) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 852c58ef41..bc8809af9c 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -3,12 +3,17 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Fiber, Layer } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { Instance } from "../../src/project/instance" import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) + +const it = testEffect( + Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)), +) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 806c47615b..60c66981d5 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,7 +5,7 @@ import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -138,9 +138,10 @@ describe("Worktree", () => { expect(props.branch).toBe(info.branch) yield* Effect.promise(() => - InstanceStore.runtime.runPromise((s) => - s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), - ), + Instance.provide({ + directory: info.directory, + fn: () => InstanceRuntime.disposeInstance(Instance.current), + }), ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) @@ -162,9 +163,10 @@ describe("Worktree", () => { yield* Effect.promise(() => ready) yield* Effect.promise(() => - InstanceStore.runtime.runPromise((s) => - s.load({ directory: info.directory }).pipe(Effect.flatMap(s.dispose)), - ), + Instance.provide({ + directory: info.directory, + fn: () => InstanceRuntime.disposeInstance(Instance.current), + }), ) yield* Effect.promise(() => Bun.sleep(100)) yield* svc.remove({ directory: info.directory }) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 83968a6f8c..694a37e99f 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,7 +1,7 @@ import { afterEach, test, expect } from "bun:test" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" @@ -422,7 +422,7 @@ test("pending question rejects on instance dispose", async () => { fn: async () => { const items = await list() expect(items).toHaveLength(1) - await InstanceStore.disposeInstance(Instance.current) + await InstanceRuntime.disposeInstance(Instance.current) }, }) @@ -457,7 +457,7 @@ test("pending question rejects on instance reload", async () => { fn: async () => { const items = await list() expect(items).toHaveLength(1) - await InstanceStore.reloadInstance({ directory: tmp.path }) + await InstanceRuntime.reloadInstance({ directory: tmp.path }) }, }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index ece01cf323..f311de2b4a 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -11,9 +11,8 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -42,8 +41,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - InstanceBootstrap.defaultLayer, - InstanceStore.defaultLayer, + InstanceRuntime.layer, Project.defaultLayer, Workspace.defaultLayer, ), diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 6f2b4cee38..396d04feb8 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -5,7 +5,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -59,7 +59,7 @@ function withMcpProject(self: (dir: string) => Effect.Effect) ) yield* Effect.addFinalizer(() => Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index b4cec9115f..8118aa7842 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -3,7 +3,7 @@ import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" -import { InstanceStore } from "../../src/project/instance-store" +import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" @@ -91,7 +91,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }), + Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index f35e044d7b..f3f7cbaef7 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -26,6 +26,7 @@ import { Snapshot } from "../../src/snapshot" import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { TestConfig } from "../fixture/config" void Log.init({ print: false }) @@ -208,7 +209,7 @@ function layer(result: "continue" | "compact") { function cfg(compaction?: Config.Info["compaction"]) { const base = Config.Info.zod.parse({}) - return Layer.mock(Config.Service)({ + return TestConfig.layer({ get: () => Effect.succeed({ ...base, compaction }), }) } diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index f800817594..3bb38c8786 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -5,8 +5,6 @@ import { FetchHttpClient } from "effect/unstable/http" import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Config } from "@/config/config" -import { emptyConsoleState } from "@/config/console-state" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" @@ -14,22 +12,11 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) -const configLayer = Layer.succeed( - Config.Service, - Config.Service.of({ - get: () => Effect.succeed({}), - getGlobal: () => Effect.succeed({}), - getConsoleState: () => Effect.succeed(emptyConsoleState), - update: () => Effect.void, - updateGlobal: (config) => Effect.succeed(config), - invalidate: () => Effect.void, - directories: () => Effect.succeed([]), - waitForDependencies: () => Effect.void, - }), -) +const configLayer = TestConfig.layer() const instructionLayer = (global: Partial) => Instruction.layer.pipe( diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 0cd3ec4d18..f9ac07831a 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -7,10 +7,50 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +import { TestConfig } from "../fixture/config" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Plugin } from "@/plugin" +import { Question } from "@/question" +import { Todo } from "@/session/todo" +import { Skill } from "@/skill" +import { Agent } from "@/agent/agent" +import { Session } from "@/session/session" +import { Provider } from "@/provider/provider" +import { LSP } from "@/lsp/lsp" +import { Instruction } from "@/session/instruction" +import { Bus } from "@/bus" +import { FetchHttpClient } from "effect/unstable/http" +import { Format } from "@/format" +import { Ripgrep } from "@/file/ripgrep" +import * as Truncate from "@/tool/truncate" +import { InstanceState } from "@/effect/instance-state" const node = CrossSpawnSpawner.defaultLayer +const configLayer = TestConfig.layer({ + directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), +}) + +const registryLayer = ToolRegistry.layer.pipe( + Layer.provide(configLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Question.defaultLayer), + Layer.provide(Todo.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Format.defaultLayer), + Layer.provide(node), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Truncate.defaultLayer), +) -const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) +const it = testEffect(Layer.mergeAll(registryLayer, node)) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 9a01f95cd1..e836b23ebe 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -9,6 +9,7 @@ import { Filesystem } from "@/util/filesystem" import path from "path" import { testEffect } from "../lib/effect" import { writeFileStringScoped } from "../lib/filesystem" +import { TestConfig } from "../fixture/config" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ROOT = path.resolve(import.meta.dir, "..", "..") @@ -19,7 +20,7 @@ const configuredLayer = (cfg: Config.Info) => Layer.mergeAll( Truncate.defaultLayer, NodeFileSystem.layer, - Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }), + TestConfig.layer({ get: () => Effect.succeed(cfg) }), ) const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg)) From 9bef88e3b072e426edfce2d7acb9a7f58ce53455 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 2 May 2026 23:34:40 +0000 Subject: [PATCH 057/178] chore: generate --- .../opencode/src/server/global-lifecycle.ts | 34 +- packages/opencode/test/fixture/fixture.ts | 8 +- .../opencode/test/tool/truncation.test.ts | 6 +- packages/sdk/js/src/v2/gen/types.gen.ts | 154 +++---- packages/sdk/openapi.json | 428 +++++++++--------- 5 files changed, 315 insertions(+), 315 deletions(-) diff --git a/packages/opencode/src/server/global-lifecycle.ts b/packages/opencode/src/server/global-lifecycle.ts index fbc300fad7..aa761a42b4 100644 --- a/packages/opencode/src/server/global-lifecycle.ts +++ b/packages/opencode/src/server/global-lifecycle.ts @@ -16,22 +16,22 @@ export const emitGlobalDisposed = Effect.sync(() => }), ) -export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn( - "Server.disposeAllInstancesAndEmitGlobalDisposed", -)(function* (options?: { swallowErrors?: boolean }) { - const store = yield* InstanceStore.Service - yield* Effect.gen(function* () { - yield* (options?.swallowErrors - ? store.disposeAll().pipe( - Effect.catchCause((cause) => - Effect.sync(() => { - log.warn("global disposal failed", { cause }) - }), - ), - ) - : store.disposeAll()) - yield* emitGlobalDisposed - }).pipe(Effect.uninterruptible) -}) +export const disposeAllInstancesAndEmitGlobalDisposed = Effect.fn("Server.disposeAllInstancesAndEmitGlobalDisposed")( + function* (options?: { swallowErrors?: boolean }) { + const store = yield* InstanceStore.Service + yield* Effect.gen(function* () { + yield* options?.swallowErrors + ? store.disposeAll().pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("global disposal failed", { cause }) + }), + ), + ) + : store.disposeAll() + yield* emitGlobalDisposed + }).pipe(Effect.uninterruptible) + }, +) export * as GlobalLifecycle from "./global-lifecycle" diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 38017e516c..e6c8aebcbd 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -155,7 +155,9 @@ export const provideInstance = Effect.contextWith((services: Context.Context) => Effect.promise(async () => { const ctx = await runTestInstanceStore((store) => store.load({ directory })) - return Instance.restore(ctx, () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx)))) + return Instance.restore(ctx, () => + Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, ctx))), + ) }), ) @@ -170,7 +172,9 @@ export function provideTmpdirInstance( yield* Effect.addFinalizer(() => provided ? Effect.promise(() => - runTestInstanceStore((store) => store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx)))), + runTestInstanceStore((store) => + store.load({ directory: path }).pipe(Effect.flatMap((ctx) => store.dispose(ctx))), + ), ).pipe(Effect.ignore) : Effect.void, ) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index e836b23ebe..e948a6dcb3 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -17,11 +17,7 @@ const ROOT = path.resolve(import.meta.dir, "..", "..") const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer)) const configuredLayer = (cfg: Config.Info) => - Layer.mergeAll( - Truncate.defaultLayer, - NodeFileSystem.layer, - TestConfig.layer({ get: () => Effect.succeed(cfg) }), - ) + Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, TestConfig.layer({ get: () => Effect.succeed(cfg) })) const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg)) describe("Truncate", () => { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index b925ec6096..e60ea76945 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -40,35 +40,6 @@ export type EventServerInstanceDisposed = { } } -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -230,6 +201,53 @@ export type EventInstallationUpdateAvailable = { } } +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -452,38 +470,6 @@ export type EventVcsBranchUpdated = { } } -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - export type EventWorktreeReady = { type: "worktree.ready" properties: { @@ -988,6 +974,20 @@ export type EventSessionDeleted = { } } +export type EventServerConnected = { + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type SyncEventMessageUpdated = { type: "sync" name: "message.updated.1" @@ -1113,10 +1113,6 @@ export type GlobalEvent = { payload: | EventProjectUpdated | EventServerInstanceDisposed - | EventServerConnected - | EventGlobalDisposed - | EventFileEdited - | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -1126,6 +1122,12 @@ export type GlobalEvent = { | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventFileEdited + | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -1141,10 +1143,6 @@ export type GlobalEvent = { | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated @@ -1158,6 +1156,8 @@ export type GlobalEvent = { | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventServerConnected + | EventGlobalDisposed | SyncEventMessageUpdated | SyncEventMessageRemoved | SyncEventMessagePartUpdated @@ -2056,10 +2056,6 @@ export type File = { export type Event = | EventProjectUpdated | EventServerInstanceDisposed - | EventServerConnected - | EventGlobalDisposed - | EventFileEdited - | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -2069,6 +2065,12 @@ export type Event = | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventFileEdited + | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -2084,10 +2086,6 @@ export type Event = | EventMcpBrowserOpenFailed | EventCommandExecuted | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated @@ -2101,6 +2099,8 @@ export type Event = | EventSessionCreated | EventSessionUpdated | EventSessionDeleted + | EventServerConnected + | EventGlobalDisposed export type McpStatusConnected = { status: "connected" diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index cfd8277a3b..0e9c11ca6e 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7693,76 +7693,6 @@ }, "required": ["type", "properties"] }, - "Event.server.connected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "server.connected" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["type", "properties"] - }, - "Event.global.disposed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "global.disposed" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["type", "properties"] - }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "Event.lsp.client.diagnostics": { "type": "object", "properties": { @@ -8225,6 +8155,144 @@ }, "required": ["type", "properties"] }, + "Event.workspace.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.edited": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.edited" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] + } + }, + "required": ["type", "properties"] + }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "QuestionOption": { "type": "object", "properties": { @@ -8743,102 +8811,6 @@ }, "required": ["type", "properties"] }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, "Event.worktree.ready": { "type": "object", "properties": { @@ -10441,6 +10413,34 @@ }, "required": ["type", "properties"] }, + "Event.server.connected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "server.connected" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["type", "properties"] + }, + "Event.global.disposed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "global.disposed" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["type", "properties"] + }, "SyncEvent.message.updated": { "type": "object", "properties": { @@ -10963,18 +10963,6 @@ { "$ref": "#/components/schemas/Event.server.instance.disposed" }, - { - "$ref": "#/components/schemas/Event.server.connected" - }, - { - "$ref": "#/components/schemas/Event.global.disposed" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" }, @@ -11002,6 +10990,24 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -11047,18 +11053,6 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, { "$ref": "#/components/schemas/Event.worktree.ready" }, @@ -11098,6 +11092,12 @@ { "$ref": "#/components/schemas/Event.session.deleted" }, + { + "$ref": "#/components/schemas/Event.server.connected" + }, + { + "$ref": "#/components/schemas/Event.global.disposed" + }, { "$ref": "#/components/schemas/SyncEvent.message.updated" }, @@ -13256,18 +13256,6 @@ { "$ref": "#/components/schemas/Event.server.instance.disposed" }, - { - "$ref": "#/components/schemas/Event.server.connected" - }, - { - "$ref": "#/components/schemas/Event.global.disposed" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" }, @@ -13295,6 +13283,24 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, + { + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -13340,18 +13346,6 @@ { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, { "$ref": "#/components/schemas/Event.worktree.ready" }, @@ -13390,6 +13384,12 @@ }, { "$ref": "#/components/schemas/Event.session.deleted" + }, + { + "$ref": "#/components/schemas/Event.server.connected" + }, + { + "$ref": "#/components/schemas/Event.global.disposed" } ] }, From 85bb9007baab8e6c5cd28ea39e9eddb15022cb5d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 19:54:13 -0400 Subject: [PATCH 058/178] feat(cli): auto-dispose InstanceContext after effectCmd handlers (#25481) --- packages/opencode/src/cli/effect-cmd.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index 29f750d160..6785e0b612 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -2,6 +2,7 @@ import type { Argv } from "yargs" import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" +import { InstanceRef } from "@/effect/instance-ref" import { cmd } from "./cmd/cmd" /** @@ -21,6 +22,11 @@ export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError( * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. * + * The handler is wrapped in `Effect.ensuring(store.dispose(ctx))` so the loaded + * InstanceContext is disposed (runDisposers + IPC `server.instance.disposed`) + * on every Exit — success, typed failure, defect, or interruption. Matches the + * legacy `bootstrap()` finally-disposal semantics without per-handler boilerplate. + * * Errors propagate to the existing top-level handler in `src/index.ts`; use * `fail("...")` for user-visible domain failures (clean exit, formatted message). * @@ -47,6 +53,17 @@ export const effectCmd = (opts: { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. const args = rawArgs as unknown as Args const directory = opts.directory?.(args) ?? process.cwd() - await AppRuntime.runPromise(InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args)))) + await AppRuntime.runPromise( + InstanceStore.Service.use((store) => + store.provide( + { directory }, + Effect.gen(function* () { + const ctx = yield* InstanceRef + const body = opts.handler(args) + return ctx ? yield* body.pipe(Effect.ensuring(store.dispose(ctx))) : yield* body + }), + ), + ), + ) }, }) From 6b68b1020e3efbeb7d09b3318495593b66f1c745 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 3 May 2026 10:09:50 +1000 Subject: [PATCH 059/178] docs: clarify LSP and formatter opt-in config (#25502) --- README.md | 2 +- packages/opencode/src/config/config.ts | 10 ++++- packages/web/src/content/docs/config.mdx | 39 +++++++++++++++++++- packages/web/src/content/docs/formatters.mdx | 29 ++++++++++----- packages/web/src/content/docs/lsp.mdx | 34 ++++++++++++----- 5 files changed, 92 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 79ccf8b349..3ebfb1627c 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ It's very similar to Claude Code in terms of capability. Here are the key differ - 100% open source - Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important. -- Out-of-the-box LSP support +- Built-in opt-in LSP support - A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. - A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients. diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a63d77013f..c6557360bb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -192,8 +192,14 @@ export const Info = Schema.Struct({ ]), ), ).annotate({ description: "MCP (Model Context Protocol) server configurations" }), - formatter: Schema.optional(ConfigFormatter.Info), - lsp: Schema.optional(ConfigLSP.Info), + formatter: Schema.optional(ConfigFormatter.Info).annotate({ + description: + "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", + }), + lsp: Schema.optional(ConfigLSP.Info).annotate({ + description: + "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", + }), instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional instruction files or patterns to include", }), diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 14eefdd81c..8568ffbb9e 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -575,7 +575,16 @@ Notice that this only works if it was not installed using a package manager such ### Formatters -You can configure code formatters through the `formatter` option. +You can enable and configure code formatters through the `formatter` option. Omit it to keep formatters disabled. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom formatters. ```json title="opencode.json" { @@ -599,6 +608,34 @@ You can configure code formatters through the `formatter` option. --- +### LSP Servers + +You can enable and configure LSP servers through the `lsp` option. Omit it to keep LSP disabled. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom LSP servers. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": { + "typescript": { + "disabled": true + } + } +} +``` + +[Learn more about LSP servers here](/docs/lsp). + +--- + ### Permissions By default, opencode **allows all operations** without requiring explicit approval. You can change this using the `permission` option. diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index dbee49dca6..ec7a965d22 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -3,7 +3,7 @@ title: Formatters description: OpenCode uses language specific formatters. --- -OpenCode automatically formats files after they are written or edited using language-specific formatters. This ensures that the code that is generated follows the code styles of your project. +OpenCode can format files after they are written or edited using language-specific formatters. Formatters are disabled by default; enable them in your config before OpenCode will run them. --- @@ -40,25 +40,36 @@ OpenCode comes with several built-in formatters for popular languages and framew | uv | .py, .pyi | `uv` command available | | zig | .zig, .zon | `zig` command available | -So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. +When formatters are enabled, OpenCode will use `prettier` for matching files if your project has `prettier` in `package.json`. --- ## How it works -When OpenCode writes or edits a file, it: +When OpenCode writes or edits a file and formatters are enabled, it: 1. Checks the file extension against all enabled formatters. 2. Runs the appropriate formatter command on the file. -3. Applies the formatting changes automatically. +3. Applies the formatting changes. -This process happens in the background, ensuring your code styles are maintained without any manual steps. +This process happens in the background for enabled formatters. --- ## Configure -You can customize formatters through the `formatter` section in your OpenCode config. +You can enable and customize formatters through the `formatter` section in your OpenCode config. + +To enable all built-in formatters, set `formatter` to `true`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "formatter": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom formatters. ```json title="opencode.json" { @@ -72,7 +83,7 @@ Each formatter configuration supports the following: | Property | Type | Description | | ------------- | -------- | ------------------------------------------------------- | | `disabled` | boolean | Set this to `true` to disable the formatter | -| `command` | string[] | The command to run for formatting | +| `command` | string[] | The command to run for formatting. Required for custom formatters; optional for built-ins. | | `environment` | object | Environment variables to set when running the formatter | | `extensions` | string[] | File extensions this formatter should handle | @@ -82,7 +93,7 @@ Let's look at some examples. ### Disabling formatters -To disable **all** formatters globally, set `formatter` to `false`: +If `formatter` is omitted, all formatters are disabled. To disable all formatters after another config enabled them, set `formatter` to `false`: ```json title="opencode.json" {3} { @@ -108,7 +119,7 @@ To disable a **specific** formatter, set `disabled` to `true`: ### Custom formatters -You can override the built-in formatters or add new ones by specifying the command, environment variables, and file extensions: +You can configure built-in formatters with options like `environment` or `extensions`. To add a custom formatter, specify a `command` and `extensions`: ```json title="opencode.json" {4-14} { diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index ad6a4644df..5854fe1f1a 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -3,7 +3,7 @@ title: LSP Servers description: OpenCode integrates with your LSP servers. --- -OpenCode integrates with your Language Server Protocol (LSP) to help the LLM interact with your codebase. It uses diagnostics to provide feedback to the LLM. +OpenCode can integrate with your Language Server Protocol (LSP) to help the LLM interact with your codebase. It uses diagnostics to provide feedback to the LLM. --- @@ -48,7 +48,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | yaml-ls | .yaml, .yml | Auto-installs Red Hat yaml-language-server | | zls | .zig, .zon | `zig` command available | -LSP servers are automatically enabled when one of the above file extensions are detected and the requirements are met. +When LSP is enabled, servers start when one of the above file extensions is detected and the requirements are met. :::note You can disable automatic LSP server downloads by setting the `OPENCODE_DISABLE_LSP_DOWNLOAD` environment variable to `true`. @@ -58,7 +58,7 @@ You can disable automatic LSP server downloads by setting the `OPENCODE_DISABLE_ ## How It Works -When opencode opens a file, it: +When LSP is enabled and opencode opens a file, it: 1. Checks the file extension against all enabled LSP servers. 2. Starts the appropriate LSP server if not already running. @@ -67,7 +67,18 @@ When opencode opens a file, it: ## Configure -You can customize LSP servers through the `lsp` section in your opencode config. +You can enable and customize LSP servers through the `lsp` section in your opencode config. + +To enable all built-in LSP servers, set `lsp` to `true`. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "lsp": true +} +``` + +Use an object to keep built-ins enabled while configuring overrides or custom servers. ```json title="opencode.json" { @@ -76,7 +87,9 @@ You can customize LSP servers through the `lsp` section in your opencode config. } ``` -Each LSP server supports the following: +Each configured LSP server entry supports the following: + +Server entries need `command` unless they only disable a server. | Property | Type | Description | | ---------------- | -------- | ------------------------------------------------- | @@ -94,11 +107,12 @@ Let's look at some examples. Use the `env` property to set environment variables when starting the LSP server: -```json title="opencode.json" {5-7} +```json title="opencode.json" {5-8} { "$schema": "https://opencode.ai/config.json", "lsp": { "rust": { + "command": ["rust-analyzer"], "env": { "RUST_LOG": "debug" } @@ -113,11 +127,13 @@ Use the `env` property to set environment variables when starting the LSP server Use the `initialization` property to pass initialization options to the LSP server. These are server-specific settings sent during the LSP `initialize` request: -```json title="opencode.json" {5-9} +```json title="opencode.json" {5-13} { "$schema": "https://opencode.ai/config.json", "lsp": { - "typescript": { + "custom-lsp": { + "command": ["custom-lsp-server", "--stdio"], + "extensions": [".custom"], "initialization": { "preferences": { "importModuleSpecifierPreference": "relative" @@ -136,7 +152,7 @@ Initialization options vary by LSP server. Check your LSP server's documentation ### Disabling LSP servers -To disable **all** LSP servers globally, set `lsp` to `false`: +If `lsp` is omitted, all LSP servers are disabled. To disable all LSP servers after another config enabled them, set `lsp` to `false`: ```json title="opencode.json" {3} { From d10fb88b66181ab710b768a23317c0b972bcd9c5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 00:10:53 +0000 Subject: [PATCH 060/178] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 6 ++++++ packages/sdk/openapi.json | 2 ++ packages/web/src/content/docs/formatters.mdx | 10 +++++----- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e60ea76945..af29de17f2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1592,6 +1592,9 @@ export type Config = { enabled: boolean } } + /** + * Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ formatter?: | boolean | { @@ -1604,6 +1607,9 @@ export type Config = { extensions?: Array } } + /** + * Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides. + */ lsp?: | boolean | { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0e9c11ca6e..680771e18b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11928,6 +11928,7 @@ } }, "formatter": { + "description": "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", "anyOf": [ { "type": "boolean" @@ -11970,6 +11971,7 @@ ] }, "lsp": { + "description": "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", "anyOf": [ { "type": "boolean" diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index ec7a965d22..58b63fa348 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -80,12 +80,12 @@ Use an object to keep built-ins enabled while configuring overrides or custom fo Each formatter configuration supports the following: -| Property | Type | Description | -| ------------- | -------- | ------------------------------------------------------- | -| `disabled` | boolean | Set this to `true` to disable the formatter | +| Property | Type | Description | +| ------------- | -------- | ------------------------------------------------------------------------------------------ | +| `disabled` | boolean | Set this to `true` to disable the formatter | | `command` | string[] | The command to run for formatting. Required for custom formatters; optional for built-ins. | -| `environment` | object | Environment variables to set when running the formatter | -| `extensions` | string[] | File extensions this formatter should handle | +| `environment` | object | Environment variables to set when running the formatter | +| `extensions` | string[] | File extensions this formatter should handle | Let's look at some examples. From fd01dc9c890057cd055a5ba1e5307597e0f04a4d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:31:21 -0400 Subject: [PATCH 061/178] test(httpapi): add route exerciser --- packages/opencode/script/httpapi-exercise.ts | 1709 +++++++++++++++++ .../src/server/routes/instance/tui.ts | 6 +- packages/opencode/src/storage/db.ts | 1 + packages/opencode/src/util/lazy.ts | 2 + packages/opencode/test/AGENTS.md | 33 +- packages/opencode/test/bus/bus-effect.test.ts | 187 +- packages/opencode/test/fixture/fixture.ts | 16 + packages/opencode/test/lib/effect.ts | 48 +- .../opencode/test/question/question.test.ts | 742 ++++--- packages/opencode/test/server/global-bus.ts | 34 + .../test/server/httpapi-config.test.ts | 20 +- .../test/server/httpapi-experimental.test.ts | 19 +- .../server/httpapi-instance-context.test.ts | 24 +- .../server/httpapi-instance.legacy.test.ts | 32 +- .../opencode/test/server/httpapi-tui.test.ts | 13 +- packages/opencode/test/tool/glob.test.ts | 78 +- packages/opencode/test/tool/grep.test.ts | 103 +- packages/opencode/test/tool/question.test.ts | 85 +- packages/opencode/test/tool/read.test.ts | 26 +- packages/opencode/test/tool/registry.test.ts | 248 ++- packages/opencode/test/tool/write.test.ts | 324 ++-- 21 files changed, 2711 insertions(+), 1039 deletions(-) create mode 100644 packages/opencode/script/httpapi-exercise.ts create mode 100644 packages/opencode/test/server/global-bus.ts diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts new file mode 100644 index 0000000000..f0faa27602 --- /dev/null +++ b/packages/opencode/script/httpapi-exercise.ts @@ -0,0 +1,1709 @@ +/** + * End-to-end exerciser for the legacy Hono instance routes and the Effect HttpApi routes. + * + * The goal is not to be a normal unit test file. This is a route-coverage and parity + * harness we can run while deleting Hono: every public route should eventually have a + * small scenario that proves the Effect route decodes requests, uses the right instance + * context, mutates storage when expected, and returns a compatible response shape. + * + * The script intentionally isolates `OPENCODE_DB` before importing modules that touch + * storage. Scenarios may create/delete sessions and reset the database after each run, + * so this must never point at a developer's real session database. + * + * DSL shape: + * - `http.get/post/...` starts a scenario for one OpenAPI route key. + * - `.seeded(...)` creates typed per-scenario state using Effect helpers on `ctx`. + * - `.at(...)` builds the request from that typed state. + * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. + * - `.mutating()` tells parity mode to run Effect and Hono in separate isolated contexts + * so destructive routes compare equivalent fresh setups instead of sharing one DB. + */ +import { Cause, ConfigProvider, Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { OpenApi } from "effect/unstable/httpapi" +import { Flag } from "@opencode-ai/core/flag/flag" +import { TestLLMServer } from "../test/lib/llm-server" +import type { Config } from "../src/config/config" +import { MessageID, PartID, type SessionID } from "../src/session/schema" +import { ModelID, ProviderID } from "../src/provider/schema" +import type { MessageV2 } from "../src/session/message-v2" +import type { Worktree } from "../src/worktree" +import type { Project } from "../src/project/project" +import path from "path" + +const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL +const exerciseGlobalRoot = process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) +process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") +process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") +process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") +process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache") +process.env.OPENCODE_DISABLE_SHARE = "true" +const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode") +const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") + +const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB +const exerciseDatabasePath = process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) +process.env.OPENCODE_DB = exerciseDatabasePath +Flag.OPENCODE_DB = exerciseDatabasePath + +void (await import("@opencode-ai/core/util/log")).init({ print: false }) + +const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const +const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const +const color = { + dim: "\x1b[2m", + green: "\x1b[32m", + red: "\x1b[31m", + yellow: "\x1b[33m", + cyan: "\x1b[36m", + reset: "\x1b[0m", +} + +type Method = (typeof Methods)[number] +type OpenApiMethod = (typeof OpenApiMethods)[number] +type Mode = "effect" | "parity" | "coverage" +type Backend = "effect" | "legacy" +type Comparison = "none" | "status" | "json" +type CaptureMode = "full" | "stream" +type ProjectOptions = { git?: boolean; config?: Partial; llm?: boolean } +type OpenApiSpec = { paths?: Record>> } +type JsonObject = Record + +type Options = { + mode: Mode + include: string | undefined + failOnMissing: boolean + failOnSkip: boolean +} + +type RequestSpec = { + path: string + headers?: Record + body?: unknown +} + +type CallResult = { + status: number + contentType: string + body: unknown + text: string +} + +type BackendApp = { + request(input: string | URL | Request, init?: RequestInit): Response | Promise +} + +/** Effect-native helpers available while setting up and asserting a scenario. */ +type ScenarioContext = { + directory: string | undefined + headers: (extra?: Record) => Record + file: (name: string, content: string) => Effect.Effect + session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect + sessionGet: (sessionID: SessionID) => Effect.Effect + project: () => Effect.Effect + message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect + messages: (sessionID: SessionID) => Effect.Effect + todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect + worktree: (input?: { name?: string }) => Effect.Effect + worktreeRemove: (directory: string) => Effect.Effect + llmText: (value: string) => Effect.Effect + llmWait: (count: number) => Effect.Effect + tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect +} + +/** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */ +type SeededContext = ScenarioContext & { + state: S +} + +type Scenario = ActiveScenario | TodoScenario +type ActiveScenario = { + kind: "active" + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: ScenarioContext, state: unknown) => RequestSpec + expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect + compare: Comparison + capture: CaptureMode + mutates: boolean + reset: boolean +} + +/** Internal builder state stays generic until `.json(...)` erases it into `ActiveScenario`. */ +type BuilderState = { + method: Method + path: string + name: string + project: ProjectOptions | undefined + seed: (ctx: ScenarioContext) => Effect.Effect + request: (ctx: SeededContext) => RequestSpec + capture: CaptureMode + mutates: boolean + reset: boolean +} +type TodoScenario = { + kind: "todo" + method: Method + path: string + name: string + reason: string +} +type Result = + | { status: "pass"; scenario: ActiveScenario } + | { status: "fail"; scenario: ActiveScenario; message: string } + | { status: "skip"; scenario: TodoScenario } + +type SessionInfo = { id: SessionID; title: string; parentID?: SessionID } +type TodoInfo = { content: string; status: string; priority: string } +type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart } + +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +type Runtime = { + PublicApi: typeof import("../src/server/routes/instance/httpapi/public")["PublicApi"] + ExperimentalHttpApiServer: typeof import("../src/server/routes/instance/httpapi/server")["ExperimentalHttpApiServer"] + Server: typeof import("../src/server/server")["Server"] + AppLayer: typeof import("../src/effect/app-runtime")["AppLayer"] + InstanceRef: typeof import("../src/effect/instance-ref")["InstanceRef"] + Instance: typeof import("../src/project/instance")["Instance"] + InstanceStore: typeof import("../src/project/instance-store")["InstanceStore"] + Session: typeof import("../src/session/session")["Session"] + Todo: typeof import("../src/session/todo")["Todo"] + Worktree: typeof import("../src/worktree")["Worktree"] + Project: typeof import("../src/project/project")["Project"] + Tui: typeof import("../src/server/routes/instance/tui") + disposeAllInstances: typeof import("../test/fixture/fixture")["disposeAllInstances"] + tmpdir: typeof import("../test/fixture/fixture")["tmpdir"] + resetDatabase: typeof import("../test/fixture/db")["resetDatabase"] +} + +let runtimePromise: Promise | undefined + +function runtime() { + return (runtimePromise ??= (async () => { + const publicApi = await import("../src/server/routes/instance/httpapi/public") + const httpApiServer = await import("../src/server/routes/instance/httpapi/server") + const server = await import("../src/server/server") + const appRuntime = await import("../src/effect/app-runtime") + const instanceRef = await import("../src/effect/instance-ref") + const instance = await import("../src/project/instance") + const instanceStore = await import("../src/project/instance-store") + const session = await import("../src/session/session") + const todo = await import("../src/session/todo") + const worktree = await import("../src/worktree") + const project = await import("../src/project/project") + const tui = await import("../src/server/routes/instance/tui") + const fixture = await import("../test/fixture/fixture") + const db = await import("../test/fixture/db") + return { + PublicApi: publicApi.PublicApi, + ExperimentalHttpApiServer: httpApiServer.ExperimentalHttpApiServer, + Server: server.Server, + AppLayer: appRuntime.AppLayer, + InstanceRef: instanceRef.InstanceRef, + Instance: instance.Instance, + InstanceStore: instanceStore.InstanceStore, + Session: session.Session, + Todo: todo.Todo, + Worktree: worktree.Worktree, + Project: project.Project, + Tui: tui, + disposeAllInstances: fixture.disposeAllInstances, + tmpdir: fixture.tmpdir, + resetDatabase: db.resetDatabase, + } + })()) +} + +class ScenarioBuilder { + private readonly state: BuilderState + + constructor(method: Method, path: string, name: string) { + this.state = { + method, + path, + name, + project: { git: true }, + seed: () => Effect.succeed(undefined as S), + request: (ctx) => ({ path, headers: ctx.headers() }), + capture: "full", + mutates: false, + reset: true, + } + } + + global() { + return this.clone({ project: undefined, request: () => ({ path: this.state.path }) }) + } + + inProject(project: ProjectOptions = { git: true }) { + return this.clone({ project }) + } + + withLlm() { + return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } }) + } + + at(request: BuilderState["request"]) { + return this.clone({ request }) + } + + mutating() { + return this.clone({ mutates: true }) + } + + preserveDatabase() { + return this.clone({ reset: false }) + } + + stream() { + return this.clone({ capture: "stream" }) + } + + /** Assert a non-JSON or shape-only response. */ + ok(status = 200, compare: Comparison = "status") { + return this.done(compare, (_ctx, result) => + Effect.sync(() => { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + }), + ) + } + + status(status = 200, inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, compare: Comparison = "status") { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (inspect) yield* inspect(ctx, result) + }), + ) + } + + /** Assert JSON status/content-type plus an optional synchronous body check. */ + json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { + return this.jsonEffect( + status, + inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, + compare, + ) + } + + /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ + jsonEffect(status = 200, inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, compare: Comparison = "json") { + return this.done(compare, (ctx, result) => + Effect.gen(function* () { + if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) + if (!looksJson(result)) throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) + if (inspect) yield* inspect(result.body, ctx) + }), + ) + } + + private clone(next: Partial>) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, next) + return builder + } + + /** + * Seed typed state before the HTTP request. The returned value becomes `ctx.state` + * for `.at(...)` and assertions, giving stateful route tests type-safe setup. + */ + seeded(seed: (ctx: ScenarioContext) => Effect.Effect) { + const builder = new ScenarioBuilder(this.state.method, this.state.path, this.state.name) + Object.assign(builder.state, this.state, { seed }) + return builder + } + + private done(compare: Comparison, expect: (ctx: SeededContext, result: CallResult) => Effect.Effect): ActiveScenario { + const state = this.state + return { + kind: "active", + method: state.method, + path: state.path, + name: state.name, + project: state.project, + seed: state.seed, + request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }), + expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result), + compare, + capture: state.capture, + mutates: state.mutates, + reset: state.reset, + } + } +} + +const http = { + get: (path: string, name: string) => new ScenarioBuilder("GET", path, name), + post: (path: string, name: string) => new ScenarioBuilder("POST", path, name), + put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name), + patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name), + delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name), +} + +const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({ + kind: "todo", + method, + path, + name, + reason, +}) + +function route(template: string, params: Record) { + return Object.entries(params).reduce((next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), template) +} + +const scenarios: Scenario[] = [ + http.get("/global/health", "global.health").global().json(200, (body) => { + object(body) + check(body.healthy === true, "server should report healthy") + }), + http + .get("/global/event", "global.event") + .global() + .stream() + .status(200, (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") + check(result.text.includes("server.connected"), "global event should emit initial connection event") + }), + "status"), + http.get("/global/config", "global.config.get").global().json(), + http + .patch("/global/config", "global.config.update") + .global() + .seeded(() => + Effect.promise(() => + Bun.write(path.join(exerciseConfigDirectory, "opencode.jsonc"), JSON.stringify({ username: "httpapi-global" }, null, 2)), + ), + ) + .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + object(body) + check(body.username === "httpapi-global", "global config update should return patched config") + const text = yield* Effect.promise(() => Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text()) + check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") + }), + "status"), + http.post("/global/dispose", "global.dispose").global().mutating().json(200, (body) => { + check(body === true, "global dispose should return true") + }, "status"), + http.get("/path", "path.get").json(200, (body, ctx) => { + object(body) + check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") + check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") + }), + http.get("/vcs", "vcs.get").json(), + http.get("/vcs/diff", "vcs.diff").at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })).json(200, array), + http.get("/command", "command.list").json(200, array, "status"), + http.get("/agent", "app.agents").json(200, array, "status"), + http.get("/skill", "app.skills").json(200, array, "status"), + http.get("/lsp", "lsp.status").json(200, array), + http.get("/formatter", "formatter.status").json(200, array), + http.get("/config", "config.get").json(200, undefined, "status"), + http + .patch("/config", "config.update") + .mutating() + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) + .json(200, (body) => { + object(body) + check(body.username === "httpapi-local", "local config update should return patched config") + }, "status"), + http + .patch("/config", "config.update.invalid") + .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) + .status(400), + http.get("/config/providers", "config.providers").json(), + http.get("/project", "project.list").json(200, array, "status"), + http.get("/project/current", "project.current").json(200, (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + }, "status"), + http + .patch("/project/{projectID}", "project.update") + .mutating() + .seeded((ctx) => ctx.project()) + .at((ctx) => ({ + path: route("/project/{projectID}", { projectID: ctx.state.id }), + headers: ctx.headers(), + body: { name: "HTTP API Project", commands: { start: "bun --version" } }, + })) + .json(200, (body) => { + object(body) + check(body.name === "HTTP API Project", "project update should return patched name") + check(isRecord(body.commands) && body.commands.start === "bun --version", "project update should return patched command") + }, "status"), + http + .post("/project/git/init", "project.initGit") + .mutating() + .inProject({ git: false }) + .json(200, (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "git init should return current project") + check(body.vcs === "git", "git init should mark the project as git-backed") + }, "status"), + http.get("/provider", "provider.list").json(), + http.get("/provider/auth", "provider.auth").json(), + http + .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") + .at((ctx) => ({ path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .status(400), + http + .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") + .at((ctx) => ({ path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .status(400), + http.get("/permission", "permission.list").json(200, array), + http + .post("/permission/{requestID}/reply", "permission.reply.invalid") + .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "bad" } })) + .status(400), + http + .post("/permission/{requestID}/reply", "permission.reply") + .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "once" } })) + .json(200, (body) => { + check(body === true, "permission reply should return true even when request is no longer pending") + }), + http.get("/question", "question.list").json(200, array), + http + .post("/question/{requestID}/reply", "question.reply.invalid") + .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: "Yes" } })) + .status(400), + http + .post("/question/{requestID}/reply", "question.reply") + .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: [["Yes"]] } })) + .json(200, (body) => { + check(body === true, "question reply should return true even when request is no longer pending") + }), + http + .post("/question/{requestID}/reject", "question.reject") + .at((ctx) => ({ path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "question reject should return true even when request is no longer pending") + }), + http + .get("/file", "file.list") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file?${new URLSearchParams({ path: "." })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/file/content", "file.read") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "hello.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.content === "hello", `content should match seeded file: ${JSON.stringify(body)}`) + }), + http + .get("/file/content", "file.read.missing") + .at((ctx) => ({ path: `/file/content?${new URLSearchParams({ path: "missing.txt" })}`, headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.type === "text" && body.content === "", "missing file content should return an empty text result") + }), + http.get("/file/status", "file.status").json(200, array), + http + .get("/find", "find.text") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/find?${new URLSearchParams({ pattern: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/find/file", "find.files") + .seeded((ctx) => ctx.file("hello.txt", "hello\n")) + .at((ctx) => ({ path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/find/symbol", "find.symbols") + .seeded((ctx) => ctx.file("hello.ts", "export const hello = 1\n")) + .at((ctx) => ({ path: `/find/symbol?${new URLSearchParams({ query: "hello" })}`, headers: ctx.headers() })) + .json(200, array), + http + .get("/event", "event.stream") + .stream() + .status(200, (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") + check(result.text.includes("server.connected"), "event should emit initial connection event") + }), + "status"), + http.get("/mcp", "mcp.status").json(), + http + .post("/mcp", "mcp.add") + .mutating() + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, + })) + .json(200, (body) => { + object(body) + object(body["httpapi-disabled"]) + check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") + }, "status"), + http + .post("/mcp", "mcp.add.invalid") + .at((ctx) => ({ path: "/mcp", headers: ctx.headers(), body: { name: "httpapi-invalid", config: { type: "invalid" } } })) + .status(400), + http + .post("/mcp/{name}/auth", "mcp.auth.start") + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(400, (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth response should include error") + }, "status"), + http + .delete("/mcp/{name}/auth", "mcp.auth.remove") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + object(body) + check(body.success === true, "MCP auth removal should return success") + }), + http + .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") + .at((ctx) => ({ path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(400, (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") + }, "status"), + http + .post("/mcp/{name}/auth/callback", "mcp.auth.callback") + .at((ctx) => ({ path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), headers: ctx.headers(), body: { code: 1 } })) + .status(400), + http + .post("/mcp/{name}/connect", "mcp.connect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/connect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP connect should remain a no-op success") + }), + http + .post("/mcp/{name}/disconnect", "mcp.disconnect") + .mutating() + .at((ctx) => ({ path: route("/mcp/{name}/disconnect", { name: "httpapi-missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing MCP disconnect should remain a no-op success") + }), + http.get("/pty/shells", "pty.shells").json(200, array), + http.get("/pty", "pty.list").json(200, array), + http + .post("/pty", "pty.create") + .mutating() + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) + .json(200, (body, ctx) => { + object(body) + check(body.title === "HTTP API PTY", "PTY create should return requested title") + check(body.command === "/bin/sh", "PTY create should use controlled shell command") + check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") + }, "status"), + http + .post("/pty", "pty.create.invalid") + .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) + .status(400), + http + .get("/pty/{ptyID}", "pty.get") + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404), + http + .put("/pty/{ptyID}", "pty.update") + .mutating() + .at((ctx) => ({ + path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), + headers: ctx.headers(), + body: { size: { rows: 0, cols: 0 } }, + })) + .status(400), + http + .delete("/pty/{ptyID}", "pty.remove") + .mutating() + .at((ctx) => ({ path: route("/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "PTY remove should return true") + }), + http + .get("/pty/{ptyID}/connect", "pty.connect") + .at((ctx) => ({ path: route("/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .status(404, undefined, "none"), + http.get("/experimental/console", "experimental.console.get").json(), + http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), + http + .post("/experimental/console/switch", "experimental.console.switchOrg") + .at((ctx) => ({ path: "/experimental/console/switch", headers: ctx.headers(), body: { accountID: "httpapi-account", orgID: "httpapi-org" } })) + .status(400, undefined, "none"), + http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), + http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), + http.get("/experimental/workspace/status", "experimental.workspace.status").json(200, array), + http + .post("/experimental/workspace", "experimental.workspace.create") + .at((ctx) => ({ path: "/experimental/workspace", headers: ctx.headers(), body: {} })) + .status(400), + http + .delete("/experimental/workspace/{id}", "experimental.workspace.remove") + .mutating() + .at((ctx) => ({ path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), headers: ctx.headers() })) + .status(200), + http + .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") + .at((ctx) => ({ path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), headers: ctx.headers(), body: {} })) + .status(400), + http + .get("/experimental/tool", "tool.list") + .at((ctx) => ({ path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, headers: ctx.headers() })) + .json(200, array, "status"), + http.get("/experimental/tool/ids", "tool.ids").json(200, array), + http.get("/experimental/worktree", "worktree.list").json(200, array), + http + .post("/experimental/worktree", "worktree.create") + .mutating() + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + object(body) + check(typeof body.directory === "string", "created worktree should include directory") + yield* ctx.worktreeRemove(body.directory) + }), + "status"), + http + .post("/experimental/worktree", "worktree.create.invalid") + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) + .status(400), + http + .delete("/experimental/worktree", "worktree.remove") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-remove" })) + .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .json(200, (body) => { + check(body === true, "worktree remove should return true") + }), + http + .post("/experimental/worktree/reset", "worktree.reset") + .mutating() + .seeded((ctx) => ctx.worktree({ name: "api-reset" })) + .at((ctx) => ({ path: "/experimental/worktree/reset", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "worktree reset should return true") + yield* ctx.worktreeRemove(ctx.state.directory) + }), + ), + http.get("/experimental/session", "experimental.session.list").json(200, array), + http.get("/experimental/resource", "experimental.resource.list").json(), + http.post("/sync/history", "sync.history.list").at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })).json(200, array), + http + .post("/sync/replay", "sync.replay") + .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) + .status(400), + http.post("/sync/start", "sync.start").mutating().preserveDatabase().json(200, (body) => { + check(body === true, "sync start should return true when no workspace sessions exist") + }), + http.post("/instance/dispose", "instance.dispose").mutating().json(200, (body) => { + check(body === true, "instance dispose should return true") + }), + http + .post("/log", "app.log") + .global() + .at(() => ({ path: "/log", body: { service: "httpapi-exercise", level: "info", message: "route coverage" } })) + .json(200, (body) => { + check(body === true, "log route should return true") + }), + http + .put("/auth/{providerID}", "auth.set") + .global() + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }), body: { type: "api", key: "test-key" } })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth set should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(isRecord(auth.test) && auth.test.key === "test-key", "auth set should write isolated auth file") + }), + ), + http + .delete("/auth/{providerID}", "auth.remove") + .global() + .seeded(() => + Effect.promise(() => + Bun.write(path.join(exerciseDataDirectory, "auth.json"), JSON.stringify({ test: { type: "api", key: "remove-me" } })), + ), + ) + .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) + .jsonEffect(200, (body) => + Effect.gen(function* () { + check(body === true, "auth remove should return true") + const auth = yield* Effect.promise(() => Bun.file(path.join(exerciseDataDirectory, "auth.json")).json()) + object(auth) + check(auth.test === undefined, "auth remove should delete provider from isolated auth file") + }), + ), + http + .get("/session", "session.list") + .seeded((ctx) => ctx.session({ title: "List me" })) + .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) + .json(200, (body, ctx) => { + array(body) + check(body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), "seeded session should be listed") + }), + http + .get("/session/status", "session.status") + .seeded((ctx) => ctx.session({ title: "Status session" })) + .json(200, object), + http + .post("/session", "session.create") + .mutating() + .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) + .json(200, (body, ctx) => { + object(body) + check(body.title === "Created session", "created session should use requested title") + check(body.directory === ctx.directory, "created session should use scenario directory") + }, "status"), + http + .get("/session/{sessionID}", "session.get") + .seeded((ctx) => ctx.session({ title: "Get me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "should return requested session") + check(body.title === "Get me", "should preserve seeded title") + }), + http + .get("/session/{sessionID}", "session.get.missing") + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .status(404), + http + .patch("/session/{sessionID}", "session.update") + .mutating() + .seeded((ctx) => ctx.session({ title: "Before rename" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers(), body: { title: "After rename" } })) + .json(200, (body) => { + object(body) + check(body.title === "After rename", "updated session should use new title") + }, "status"), + http + .patch("/session/{sessionID}", "session.update.invalid") + .mutating() + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers(), body: { title: 1 } })) + .status(400), + http + .delete("/session/{sessionID}", "session.delete") + .mutating() + .seeded((ctx) => ctx.session({ title: "Delete me" })) + .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete should return true") + check((yield* ctx.sessionGet(ctx.state.id)) === undefined, "deleted session should not remain in storage") + }), + ), + http + .get("/session/{sessionID}/children", "session.children") + .seeded((ctx) => + Effect.gen(function* () { + const parent = yield* ctx.session({ title: "Parent" }) + const child = yield* ctx.session({ title: "Child", parentID: parent.id }) + return { parent, child } + }), + ) + .at((ctx) => ({ path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + array(body) + check(body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), "children should include seeded child") + }), + http + .get("/session/{sessionID}/todo", "session.todo") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Todo session" }) + const todos = [{ content: "cover session todo", status: "pending", priority: "high" }] + yield* ctx.todos(session.id, todos) + return { session, todos } + }), + ) + .at((ctx) => ({ path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") + }), + http + .get("/session/{sessionID}/diff", "session.diff") + .seeded((ctx) => ctx.session({ title: "Diff session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/diff", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, array), + http + .get("/session/{sessionID}/message", "session.messages") + .seeded((ctx) => ctx.session({ title: "Messages session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + array(body) + check(body.length === 0, "new session should have no messages") + }), + http + .get("/session/{sessionID}/message/{messageID}", "session.message") + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message get session" }) + const message = yield* ctx.message(session.id, { text: "read me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .json(200, (body, ctx) => { + object(body) + check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") + check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), "message should include seeded part") + }), + http + .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part update session" }) + const message = yield* ctx.message(session.id, { text: "before" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + body: { ...ctx.state.message.part, text: "after" }, + })) + .json(200, (body) => { + object(body) + check(body.type === "text" && body.text === "after", "updated part should be returned") + }, "status"), + http + .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Part delete session" }) + const message = yield* ctx.message(session.id, { text: "delete part" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}/part/{partID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + partID: ctx.state.message.part.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete part should return true") + const messages = yield* ctx.messages(ctx.state.session.id) + check(messages[0]?.parts.length === 0, "deleted part should not remain on message") + }), + ), + http + .delete("/session/{sessionID}/message/{messageID}", "session.deleteMessage") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Message delete session" }) + const message = yield* ctx.message(session.id, { text: "delete message" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message/{messageID}", { + sessionID: ctx.state.session.id, + messageID: ctx.state.message.info.id, + }), + headers: ctx.headers(), + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "delete message should return true") + check((yield* ctx.messages(ctx.state.session.id)).length === 0, "deleted message should not remain") + }), + ), + http + .post("/session/{sessionID}/fork", "session.fork") + .mutating() + .seeded((ctx) => ctx.session({ title: "Fork source" })) + .at((ctx) => ({ path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), headers: ctx.headers(), body: {} })) + .json(200, (body) => { + object(body) + check(typeof body.id === "string", "fork should return a session") + }, "status"), + http + .post("/session/{sessionID}/abort", "session.abort") + .mutating() + .seeded((ctx) => ctx.session({ title: "Abort session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "abort should return true") + }), + http + .post("/session/{sessionID}/abort", "session.abort.missing") + .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .json(200, (body) => { + check(body === true, "missing session abort should remain a no-op success") + }), + http + .post("/session/{sessionID}/init", "session.init") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Init session" }) + const message = yield* ctx.message(session.id, { text: "initialize" }) + yield* ctx.llmText("initialized") + yield* ctx.llmText("initialized") + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/init", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", messageID: ctx.state.message.info.id }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "init should return true") + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/message", "session.prompt") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "LLM prompt session" }) + yield* ctx.llmText("fake assistant") + yield* ctx.llmText("fake assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/message", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello llm" }], + }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") + check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), "assistant message should use fake LLM text") + yield* ctx.llmWait(1) + }), + "status"), + http + .post("/session/{sessionID}/prompt_async", "session.prompt_async") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Async prompt session" }) + yield* ctx.llmText("fake async assistant") + yield* ctx.llmText("fake async assistant") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/prompt_async", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { + agent: "build", + model: { providerID: "test", modelID: "test-model" }, + parts: [{ type: "text", text: "hello async" }], + }, + })) + .status(204, (ctx) => + Effect.gen(function* () { + yield* ctx.llmWait(1) + }), + ), + http + .post("/session/{sessionID}/command", "session.command") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Command session" }) + yield* ctx.llmText("command done") + yield* ctx.llmText("command done") + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/command", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { command: "init", arguments: "", model: "test/test-model" }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") + yield* ctx.llmWait(1) + }), + "status"), + http + .post("/session/{sessionID}/shell", "session.shell") + .preserveDatabase() + .mutating() + .seeded((ctx) => ctx.session({ title: "Shell session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/shell", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, + })) + .json(200, (body) => { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") + check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), "shell should return a tool part") + }, "status"), + http + .post("/session/{sessionID}/summarize", "session.summarize") + .preserveDatabase() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Summarize session" }) + yield* ctx.message(session.id, { text: "summarize this work" }) + const summary = [ + "## Goal", + "- Exercise session summarize.", + "", + "## Constraints & Preferences", + "- Use fake LLM.", + "", + "## Progress", + "### Done", + "- Summary generated.", + "", + "### In Progress", + "- (none)", + "", + "### Blocked", + "- (none)", + "", + "## Key Decisions", + "- Keep route local.", + "", + "## Next Steps", + "- (none)", + "", + "## Critical Context", + "- Test fixture.", + "", + "## Relevant Files", + "- script/httpapi-exercise.ts: scenario", + ].join("\n") + yield* ctx.llmText(summary) + yield* ctx.llmText(summary) + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/summarize", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", auto: false }, + })) + .jsonEffect(200, (body, ctx) => + Effect.gen(function* () { + check(body === true, "summarize should return true") + const messages = yield* ctx.messages(ctx.state.id) + check( + messages.some((message) => message.info.role === "assistant" && message.info.summary === true), + "summarize should create a summary assistant message", + ) + yield* ctx.llmWait(1) + }), + "status"), + http + .post("/session/{sessionID}/revert", "session.revert") + .mutating() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Revert session" }) + const message = yield* ctx.message(session.id, { text: "revert me" }) + return { session, message } + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/revert", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + body: { messageID: ctx.state.message.info.id }, + })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.session.id, "revert should return the session") + check(isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, "revert should record reverted message") + }, "status"), + http + .post("/session/{sessionID}/unrevert", "session.unrevert") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unrevert session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unrevert should return the session") + }, "status"), + http + .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") + .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) + .at((ctx) => ({ + path: route("/session/{sessionID}/permissions/{permissionID}", { sessionID: ctx.state.id, permissionID: "per_httpapi_deprecated" }), + headers: ctx.headers(), + body: { response: "once" }, + })) + .json(200, (body) => { + check(body === true, "deprecated permission response should return true") + }), + http + .post("/session/{sessionID}/share", "session.share") + .mutating() + .seeded((ctx) => ctx.session({ title: "Share session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "share should return the session") + }, "status"), + http + .delete("/session/{sessionID}/share", "session.unshare") + .mutating() + .seeded((ctx) => ctx.session({ title: "Unshare session" })) + .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) + .json(200, (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unshare should return the session") + }, "status"), + http + .post("/tui/append-prompt", "tui.appendPrompt") + .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession.invalid") + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: "invalid" } })) + .status(400), + http.post("/tui/open-help", "tui.openHelp").json(200, boolean, "status"), + http.post("/tui/open-sessions", "tui.openSessions").json(200, boolean, "status"), + http.post("/tui/open-themes", "tui.openThemes").json(200, boolean, "status"), + http.post("/tui/open-models", "tui.openModels").json(200, boolean, "status"), + http.post("/tui/submit-prompt", "tui.submitPrompt").json(200, boolean, "status"), + http.post("/tui/clear-prompt", "tui.clearPrompt").json(200, boolean, "status"), + http + .post("/tui/execute-command", "tui.executeCommand") + .at((ctx) => ({ path: "/tui/execute-command", headers: ctx.headers(), body: { command: "agent_cycle" } })) + .json(200, boolean, "status"), + http + .post("/tui/show-toast", "tui.showToast") + .at((ctx) => ({ + path: "/tui/show-toast", + headers: ctx.headers(), + body: { title: "Exercise", message: "covered", variant: "info", duration: 1000 }, + })) + .json(200, boolean, "status"), + http + .post("/tui/publish", "tui.publish") + .at((ctx) => ({ + path: "/tui/publish", + headers: ctx.headers(), + body: { type: "tui.prompt.append", properties: { text: "published" } }, + })) + .json(200, boolean, "status"), + http + .post("/tui/select-session", "tui.selectSession") + .seeded((ctx) => ctx.session({ title: "TUI select" })) + .at((ctx) => ({ path: "/tui/select-session", headers: ctx.headers(), body: { sessionID: ctx.state.id } })) + .json(200, boolean, "status"), + http + .post("/tui/control/response", "tui.control.response") + .at((ctx) => ({ path: "/tui/control/response", headers: ctx.headers(), body: { ok: true } })) + .json(200, boolean, "status"), + http + .get("/tui/control/next", "tui.control.next") + .mutating() + .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) + .json(200, (body) => { + object(body) + check(body.path === "/tui/exercise", "control next should return queued path") + object(body.body) + check(body.body.text === "queued", "control next should return queued body") + }, "status"), + http.post("/global/upgrade", "global.upgrade").global().at(() => ({ path: "/global/upgrade", body: { target: 1 } })).status(400), +] + +const main = Effect.gen(function* () { + yield* Effect.addFinalizer(() => cleanupExercisePaths) + const options = parseOptions(Bun.argv.slice(2)) + const modules = yield* Effect.promise(() => runtime()) + const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) + const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapi())) + const selected = scenarios.filter((scenario) => matches(options, scenario)) + const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) + const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) + + printHeader(options, effectRoutes, honoRoutes, selected, missing, extra) + + const results = options.mode === "coverage" ? selected.map(coverageResult) : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) + printResults(results, missing, extra) + + if (results.some((result) => result.status === "fail")) return yield* Effect.fail(new Error("one or more scenarios failed")) + if (options.failOnSkip && results.some((result) => result.status === "skip")) return yield* Effect.fail(new Error("one or more scenarios are skipped")) + if (options.failOnMissing && missing.length > 0) return yield* Effect.fail(new Error("one or more routes have no scenario")) +}) + +function runScenario(options: Options) { + return (scenario: Scenario) => { + if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result) + return runActive(options, scenario).pipe( + Effect.as({ status: "pass", scenario } as Result), + Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })), + Effect.scoped, + ) + } +} + +function runActive(options: Options, scenario: ActiveScenario) { + if (options.mode === "parity" && scenario.mutates && scenario.compare !== "none") { + return Effect.gen(function* () { + const effect = yield* runBackend("effect", scenario) + const legacy = yield* runBackend("legacy", scenario) + yield* compare(scenario, effect, legacy) + }) + } + + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const effect = yield* call("effect", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, effect) + if (options.mode === "parity" && scenario.compare !== "none") { + const legacy = yield* call("legacy", scenario, ctx) + yield* scenario.expect(ctx, ctx.state, legacy) + yield* compare(scenario, effect, legacy) + } + }), + ) +} + +function runBackend(backend: "effect" | "legacy", scenario: ActiveScenario) { + return withContext(scenario, (ctx) => + Effect.gen(function* () { + const result = yield* call(backend, scenario, ctx) + yield* scenario.expect(ctx, ctx.state, result) + return result + }), + ) +} + +function withContext(scenario: ActiveScenario, use: (ctx: SeededContext) => Effect.Effect) { + return Effect.acquireRelease( + Effect.gen(function* () { + const llm = scenario.project?.llm ? yield* TestLLMServer : undefined + const project = scenario.project + const dir = project + ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url))) + : undefined + return { dir, llm } + }), + (ctx) => Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), + ).pipe( + Effect.flatMap((context) => Effect.gen(function* () { + const modules = yield* Effect.promise(() => runtime()) + const path = context.dir?.path + const instance = path + ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + Effect.catchCause((cause) => + Effect.sleep("100 millis").pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + ), + ), + Effect.catchCause(() => Effect.failCause(cause)), + ), + ), + ) + : undefined + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) + const directory = () => { + if (!context.dir?.path) throw new Error("scenario needs a project directory") + return context.dir.path + } + const llm = () => { + if (!context.llm) throw new Error("scenario needs fake LLM") + return context.llm + } + const base: ScenarioContext = { + directory: context.dir?.path, + headers: (extra) => ({ ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), ...extra }), + file: (name, content) => + Effect.promise(() => { + return Bun.write(`${directory()}/${name}`, content) + }).pipe(Effect.asVoid), + session: (input) => + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + sessionGet: (sessionID) => + run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ), + project: () => + Effect.sync(() => { + if (!instance) throw new Error("scenario needs a project directory") + return instance.project + }), + message: (sessionID, input) => + Effect.gen(function* () { + const info: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + }, + } + const part: MessageV2.TextPart = { + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text: input?.text ?? "hello", + } + yield* run( + modules.Session.Service.use((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(info) + yield* svc.updatePart(part) + }), + ), + ) + return { info, part } + }), + messages: (sessionID) => + run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), + todos: (sessionID, todos) => + run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), + worktree: (input) => + run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktreeRemove: (directory) => + run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), + llmText: (value) => Effect.suspend(() => llm().text(value)), + llmWait: (count) => Effect.suspend(() => llm().wait(count)), + tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), + } + const state = yield* scenario.seed(base) + return yield* use({ ...base, state }) + }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void))), + Effect.ensuring(scenario.reset ? resetState : Effect.void), + ) +} + +function projectOptions(project: ProjectOptions, llmUrl: string | undefined): { git?: boolean; config?: Partial } { + if (!project.llm || !llmUrl) return { git: project.git, config: project.config } + const fake = fakeLlmConfig(llmUrl) + return { + git: project.git, + config: { + ...fake, + ...project.config, + provider: { + ...fake.provider, + ...project.config?.provider, + }, + }, + } +} + +function fakeLlmConfig(url: string): Partial { + return { + model: "test/test-model", + small_model: "test/test-model", + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: url, + }, + }, + }, + } +} + +function controlledPtyInput(title: string | undefined) { + return { + command: "/bin/sh", + args: ["-c", "sleep 30"], + ...(title ? { title } : {}), + } +} + +function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { + return Effect.promise(async () => capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture)) +} + +const appCache: Partial> = {} + +function app(modules: Runtime, backend: Backend) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect" + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = undefined + if (appCache[backend]) return appCache[backend] + if (backend === "legacy") { + const legacy = modules.Server.Legacy().app + return (appCache.legacy = { + request: (input, init) => legacy.request(input, init), + }) + } + + const handler = HttpRouter.toWebHandler( + modules.ExperimentalHttpApiServer.routes.pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }))), + ), + { disableLogger: true }, + ).handler + return (appCache.effect = { + request(input: string | URL | Request, init?: RequestInit) { + return handler(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), modules.ExperimentalHttpApiServer.context) + }, + }) +} + +function toRequest(scenario: ActiveScenario, ctx: SeededContext) { + const spec = scenario.request(ctx, ctx.state) + return new Request(new URL(spec.path, "http://localhost"), { + method: scenario.method, + headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers }, + body: spec.body === undefined ? undefined : JSON.stringify(spec.body), + }) +} + +async function capture(response: Response, mode: CaptureMode): Promise { + const text = mode === "stream" ? await captureStream(response) : await response.text() + return { + status: response.status, + contentType: response.headers.get("content-type") ?? "", + text, + body: parse(text), + } +} + +async function captureStream(response: Response) { + if (!response.body) return "" + const reader = response.body.getReader() + const read = reader.read().then( + (result) => ({ result }), + (error: unknown) => ({ error }), + ) + const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))]) + if ("timeout" in winner) { + await reader.cancel("timed out waiting for stream chunk").catch(() => undefined) + throw new Error("timed out waiting for stream chunk") + } + if ("error" in winner) throw winner.error + await reader.cancel().catch(() => undefined) + if (winner.result.done) return "" + return new TextDecoder().decode(winner.result.value) +} + +const cleanupExercisePaths = Effect.promise(async () => { + const fs = await import("fs/promises") + if (!preserveExerciseDatabase) { + await Promise.all([exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => fs.rm(file, { force: true }).catch(() => undefined))) + } + if (!preserveExerciseGlobalRoot) await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) +}) + +function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { + return Effect.sync(() => { + if (effect.status !== legacy.status) throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) + if (scenario.compare === "status") return + if (stable(effect.body) !== stable(legacy.body)) throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) + }) +} + +const resetState = Effect.promise(async () => { + const modules = await runtime() + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + await modules.disposeAllInstances() + await modules.resetDatabase() + await Bun.sleep(25) +}) + +function routeKeys(spec: OpenApiSpec) { + return Object.entries(spec.paths ?? {}) + .flatMap(([path, item]) => OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`)) + .sort() +} + +function routeKey(scenario: Scenario) { + return `${scenario.method} ${scenario.path}` +} + +function coverageResult(scenario: Scenario): Result { + if (scenario.kind === "todo") return { status: "skip", scenario } + return { status: "pass", scenario } +} + +function parseOptions(args: string[]): Options { + const mode = option(args, "--mode") ?? "effect" + if (mode !== "effect" && mode !== "parity" && mode !== "coverage") throw new Error(`invalid --mode ${mode}`) + return { + mode, + include: option(args, "--include"), + failOnMissing: args.includes("--fail-on-missing"), + failOnSkip: args.includes("--fail-on-skip"), + } +} + +function option(args: string[], name: string) { + const index = args.indexOf(name) + if (index === -1) return undefined + return args[index + 1] +} + +function matches(options: Options, scenario: Scenario) { + if (!options.include) return true + return scenario.name.includes(options.include) || scenario.path.includes(options.include) || scenario.method.includes(options.include.toUpperCase()) +} + +function printHeader(options: Options, effectRoutes: string[], honoRoutes: string[], selected: Scenario[], missing: string[], extra: Scenario[]) { + console.log(`${color.cyan}HttpApi exerciser${color.reset}`) + console.log(`${color.dim}db=${exerciseDatabasePath}${color.reset}`) + console.log(`${color.dim}global=${exerciseGlobalRoot}${color.reset}`) + console.log( + `${color.dim}mode=${options.mode} selected=${selected.length} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length} onlyEffect=${effectRoutes.filter((route) => !honoRoutes.includes(route)).length} onlyHono=${honoRoutes.filter((route) => !effectRoutes.includes(route)).length}${color.reset}`, + ) + console.log("") +} + +function printResults(results: Result[], missing: string[], extra: Scenario[]) { + for (const result of results) { + if (result.status === "pass") { + console.log(`${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + continue + } + if (result.status === "skip") { + console.log(`${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`) + continue + } + console.log(`${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + console.log(`${color.red}${indent(result.message)}${color.reset}`) + } + if (missing.length > 0) { + console.log("\nMissing scenarios") + for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`) + } + if (extra.length > 0) { + console.log("\nExtra scenarios") + for (const scenario of extra) console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) + } + console.log( + `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, + ) +} + +function parse(text: string): unknown { + if (!text) return undefined + try { + return JSON.parse(text) as unknown + } catch { + return text + } +} + +function looksJson(result: CallResult) { + return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[") +} + +function stable(value: unknown): string { + return JSON.stringify(sort(value)) +} + +function sort(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sort) + if (!value || typeof value !== "object") return value + return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, sort(item)])) +} + +function array(value: unknown): asserts value is unknown[] { + if (!Array.isArray(value)) throw new Error("expected array") +} + +function object(value: unknown): asserts value is JsonObject { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object") +} + +function boolean(value: unknown): asserts value is boolean { + if (typeof value !== "boolean") throw new Error("expected boolean") +} + +function isRecord(value: unknown): value is JsonObject { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function check(value: boolean, message: string): asserts value { + if (!value) throw new Error(message) +} + +function message(error: unknown) { + if (error instanceof Error) return error.message + return String(error) +} + +function pad(value: string, size: number) { + return value.length >= size ? value : value + " ".repeat(size - value.length) +} + +function indent(value: string) { + return value + .split("\n") + .map((line) => ` ${line}`) + .join("\n") +} + +Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( + () => process.exit(0), + (error: unknown) => { + console.error(`${color.red}${message(error)}${color.reset}`) + process.exit(1) + }, +) diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index 48399a5f4d..d2be015211 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -26,13 +26,17 @@ export function nextTuiRequest() { return request.next() } +export function submitTuiRequest(body: TuiRequest) { + request.push(body) +} + export function submitTuiResponse(body: unknown) { response.push(body) } export async function callTui(ctx: Context) { const body = await ctx.req.json() - request.push({ + submitTuiRequest({ path: ctx.req.path, body, }) diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index de4683b751..06cb99f97f 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -122,6 +122,7 @@ export const Client = lazy(() => { }) export function close() { + if (!Client.loaded()) return Client().$client.close() Client.reset() } diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts index 86967e11a0..d9abf18a52 100644 --- a/packages/opencode/src/util/lazy.ts +++ b/packages/opencode/src/util/lazy.ts @@ -14,5 +14,7 @@ export function lazy(fn: () => T) { value = undefined } + result.loaded = () => loaded + return result } diff --git a/packages/opencode/test/AGENTS.md b/packages/opencode/test/AGENTS.md index 00564a17bf..41372b15a0 100644 --- a/packages/opencode/test/AGENTS.md +++ b/packages/opencode/test/AGENTS.md @@ -89,20 +89,17 @@ Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect s ```typescript import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect(Layer.mergeAll(MyService.defaultLayer)) describe("my service", () => { - it.live("does the thing", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const svc = yield* MyService.Service - const out = yield* svc.run() - expect(out).toEqual("ok") - }), - ), + it.instance("does the thing", () => + Effect.gen(function* () { + const svc = yield* MyService.Service + const out = yield* svc.run() + expect(out).toEqual("ok") + }), ) }) ``` @@ -111,6 +108,7 @@ describe("my service", () => { - Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`. - Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior. +- Use `it.instance(...)` for live Effect tests that need a scoped temporary directory and instance context. - Most integration-style tests in this package use `it.live(...)`. ### Effect Fixtures @@ -122,7 +120,20 @@ Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a - `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup. - `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server. -Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test. +Use `it.instance(...)` by default when a test only needs one temp instance. Yield `TestInstance` from `fixture/fixture.ts` when the test needs the temp directory path: + +```typescript +import { TestInstance } from "../fixture/fixture" + +it.instance("uses the temp directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + expect(test.directory).toContain("opencode-test-") + }), +) +``` + +Use `provideTmpdirInstance(...)` or `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime. ### Style @@ -130,4 +141,4 @@ Use `provideTmpdirInstance(...)` by default when a test only needs one temp inst - Keep the test body inside `Effect.gen(function* () { ... })`. - Yield services directly with `yield* MyService.Service` or `yield* MyTool`. - Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime. -- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests. +- When a test needs instance-local state, prefer `it.instance(...)` over manual `Instance.provide(...)` inside Promise-style tests. diff --git a/packages/opencode/test/bus/bus-effect.test.ts b/packages/opencode/test/bus/bus-effect.test.ts index 101d3be72b..377c541096 100644 --- a/packages/opencode/test/bus/bus-effect.test.ts +++ b/packages/opencode/test/bus/bus-effect.test.ts @@ -2,9 +2,8 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Layer, Schema, Stream } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" -import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { disposeAllInstances, provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const TestEvent = { @@ -19,111 +18,103 @@ const live = Layer.mergeAll(Bus.layer, node) const it = testEffect(live) describe("Bus (Effect-native)", () => { - it.live("publish + subscribe stream delivers events", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const received: number[] = [] - const done = yield* Deferred.make() - - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - received.push(evt.properties.value) - if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Ping, { value: 2 }) - yield* Deferred.await(done) - - expect(received).toEqual([1, 2]) - }), - ), + it.instance("publish + subscribe stream delivers events", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const received: number[] = [] + const done = yield* Deferred.make() + + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + received.push(evt.properties.value) + if (received.length === 2) Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) + + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Ping, { value: 2 }) + yield* Deferred.await(done) + + expect(received).toEqual([1, 2]) + }), ) - it.live("subscribe filters by event type", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const pings: number[] = [] - const done = yield* Deferred.make() - - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - pings.push(evt.properties.value) - Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Pong, { message: "ignored" }) - yield* bus.publish(TestEvent.Ping, { value: 42 }) - yield* Deferred.await(done) - - expect(pings).toEqual([42]) - }), - ), + it.instance("subscribe filters by event type", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const pings: number[] = [] + const done = yield* Deferred.make() + + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + pings.push(evt.properties.value) + Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) + + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Pong, { message: "ignored" }) + yield* bus.publish(TestEvent.Ping, { value: 42 }) + yield* Deferred.await(done) + + expect(pings).toEqual([42]) + }), ) - it.live("subscribeAll receives all types", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const types: string[] = [] - const done = yield* Deferred.make() + it.instance("subscribeAll receives all types", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const types: string[] = [] + const done = yield* Deferred.make() - yield* Stream.runForEach(bus.subscribeAll(), (evt) => - Effect.sync(() => { - types.push(evt.type) - if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) - }), - ).pipe(Effect.forkScoped) + yield* Stream.runForEach(bus.subscribeAll(), (evt) => + Effect.sync(() => { + types.push(evt.type) + if (types.length === 2) Deferred.doneUnsafe(done, Effect.void) + }), + ).pipe(Effect.forkScoped) - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 1 }) - yield* bus.publish(TestEvent.Pong, { message: "hi" }) - yield* Deferred.await(done) + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 1 }) + yield* bus.publish(TestEvent.Pong, { message: "hi" }) + yield* Deferred.await(done) - expect(types).toContain("test.effect.ping") - expect(types).toContain("test.effect.pong") - }), - ), + expect(types).toContain("test.effect.ping") + expect(types).toContain("test.effect.pong") + }), ) - it.live("multiple subscribers each receive the event", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const bus = yield* Bus.Service - const a: number[] = [] - const b: number[] = [] - const doneA = yield* Deferred.make() - const doneB = yield* Deferred.make() - - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - a.push(evt.properties.value) - Deferred.doneUnsafe(doneA, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => - Effect.sync(() => { - b.push(evt.properties.value) - Deferred.doneUnsafe(doneB, Effect.void) - }), - ).pipe(Effect.forkScoped) - - yield* Effect.sleep("10 millis") - yield* bus.publish(TestEvent.Ping, { value: 99 }) - yield* Deferred.await(doneA) - yield* Deferred.await(doneB) - - expect(a).toEqual([99]) - expect(b).toEqual([99]) - }), - ), + it.instance("multiple subscribers each receive the event", () => + Effect.gen(function* () { + const bus = yield* Bus.Service + const a: number[] = [] + const b: number[] = [] + const doneA = yield* Deferred.make() + const doneB = yield* Deferred.make() + + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + a.push(evt.properties.value) + Deferred.doneUnsafe(doneA, Effect.void) + }), + ).pipe(Effect.forkScoped) + + yield* Stream.runForEach(bus.subscribe(TestEvent.Ping), (evt) => + Effect.sync(() => { + b.push(evt.properties.value) + Deferred.doneUnsafe(doneB, Effect.void) + }), + ).pipe(Effect.forkScoped) + + yield* Effect.sleep("10 millis") + yield* bus.publish(TestEvent.Ping, { value: 99 }) + yield* Deferred.await(doneA) + yield* Deferred.await(doneB) + + expect(a).toEqual([99]) + expect(b).toEqual([99]) + }), ) it.live("subscribeAll stream sees InstanceDisposed on disposal", () => diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index e6c8aebcbd..970365f533 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -6,6 +6,7 @@ import path from "path" import { Effect, Context, Layer, ManagedRuntime } from "effect" import type * as PlatformError from "effect/PlatformError" import type * as Scope from "effect/Scope" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" @@ -184,6 +185,21 @@ export function provideTmpdirInstance( }) } +export class TestInstance extends Context.Service()("@test/Instance") {} + +export const withTmpdirInstance = + (options?: { git?: boolean; config?: Partial }) => + (self: Effect.Effect) => + Effect.gen(function* () { + const directory = yield* tmpdirScoped(options) + return yield* InstanceStore.Service.use((store) => + store.provide({ directory }, self.pipe(Effect.provideService(TestInstance, { directory }))), + ) + }).pipe( + Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))), + Effect.provide(CrossSpawnSpawner.defaultLayer), + ) + export function provideTmpdirServer( self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect, options?: { git?: boolean; config?: (url: string) => Partial }, diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index 131ec5cc6b..2fbf5ca11b 100644 --- a/packages/opencode/test/lib/effect.ts +++ b/packages/opencode/test/lib/effect.ts @@ -3,8 +3,24 @@ import { Cause, Effect, Exit, Layer } from "effect" import type * as Scope from "effect/Scope" import * as TestClock from "effect/testing/TestClock" import * as TestConsole from "effect/testing/TestConsole" +import type { Config } from "@/config/config" +import { TestInstance, withTmpdirInstance } from "../fixture/fixture" type Body = Effect.Effect | (() => Effect.Effect) +type InstanceOptions = { git?: boolean; config?: Partial } + +function isInstanceOptions(options: InstanceOptions | number | TestOptions | undefined): options is InstanceOptions { + return !!options && typeof options === "object" && ("git" in options || "config" in options) +} + +function instanceArgs( + options?: InstanceOptions | number | TestOptions, + testOptions?: number | TestOptions, +): { instanceOptions: InstanceOptions | undefined; testOptions: number | TestOptions | undefined } { + if (typeof options === "number") return { instanceOptions: undefined, testOptions: options } + if (isInstanceOptions(options)) return { instanceOptions: options, testOptions } + return { instanceOptions: undefined, testOptions: options } +} const body = (value: Body) => Effect.suspend(() => (typeof value === "function" ? value() : value)) @@ -38,7 +54,37 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) live.skip = (name: string, value: Body, opts?: number | TestOptions) => test.skip(name, () => run(value, liveLayer), opts) - return { effect, live } + const instance = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + } + + instance.only = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test.only(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + } + + instance.skip = ( + name: string, + value: Body, + options?: InstanceOptions | number | TestOptions, + opts?: number | TestOptions, + ) => { + const args = instanceArgs(options, opts) + return test.skip(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + } + + return { effect, live, instance } } // Test environment with TestClock and TestConsole diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 694a37e99f..461fb88f26 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,65 +1,64 @@ -import { afterEach, test, expect } from "bun:test" +import { afterEach, expect } from "bun:test" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" import { SessionID } from "../../src/session/schema" -import { AppRuntime } from "../../src/effect/app-runtime" - -const ask = (input: { sessionID: SessionID; questions: ReadonlyArray; tool?: Question.Tool }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input))) +import { testEffect } from "../lib/effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" + +const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer)) + +const askEffect = Effect.fn("QuestionTest.ask")(function* (input: { + sessionID: SessionID + questions: ReadonlyArray + tool?: Question.Tool +}) { + const question = yield* Question.Service + return yield* question.ask(input) +}) -const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list())) +const listEffect = Question.Service.use((svc) => svc.list()) -const reply = (input: { requestID: QuestionID; answers: ReadonlyArray }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input))) +const replyEffect = Effect.fn("QuestionTest.reply")(function* (input: { + requestID: QuestionID + answers: ReadonlyArray +}) { + const question = yield* Question.Service + yield* question.reply(input) +}) -const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id))) +const rejectEffect = Effect.fn("QuestionTest.reject")(function* (id: QuestionID) { + const question = yield* Question.Service + yield* question.reject(id) +}) afterEach(async () => { await disposeAllInstances() }) /** Reject all pending questions so dangling Deferred fibers don't hang the test. */ -async function rejectAll() { - const pending = await list() - for (const req of pending) { - await reject(req.id) - } -} - -test("ask - returns pending promise", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - expect(promise).toBeInstanceOf(Promise) - await rejectAll() - await promise.catch(() => {}) - }, - }) +const rejectAll = Effect.gen(function* () { + yield* Effect.forEach(yield* listEffect, (req) => rejectEffect(req.id), { discard: true }) }) -test("ask - adds to pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const questions = [ +const waitForPending = (count: number) => + Effect.gen(function* () { + for (let i = 0; i < 100; i++) { + const pending = yield* listEffect + if (pending.length === count) return pending + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error(`timed out waiting for ${count} pending question request(s)`)) + }) + +it.instance("ask - remains pending until answered", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { question: "What would you like to do?", header: "Action", @@ -68,30 +67,81 @@ test("ask - adds to pending list", async () => { { label: "Option 2", description: "Second option" }, ], }, - ] - - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions, - }) - - const pending = await list() - expect(pending.length).toBe(1) - expect(pending[0].questions).toEqual(questions) - await rejectAll() - await promise.catch(() => {}) - }, - }) -}) + ], + }).pipe(Effect.forkScoped) + + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) + +it.instance("ask - adds to pending list", () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ] + + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) + expect(pending[0].questions).toEqual(questions) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) // reply tests -test("reply - resolves the pending ask with answers", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const questions = [ +it.instance("reply - resolves the pending ask with answers", () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ] + + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + const requestID = pending[0].id + + yield* replyEffect({ + requestID, + answers: [["Option 1"]], + }) + + expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]]) + }), + { git: true }, +) + +it.instance("reply - removes from pending list", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { question: "What would you like to do?", header: "Action", @@ -100,366 +150,260 @@ test("reply - resolves the pending ask with answers", async () => { { label: "Option 2", description: "Second option" }, ], }, - ] - - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions, - }) - - const pending = await list() - const requestID = pending[0].id - - await reply({ - requestID, - answers: [["Option 1"]], - }) - - const answers = await promise - expect(answers).toEqual([["Option 1"]]) - }, - }) -}) - -test("reply - removes from pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - - const pending = await list() - expect(pending.length).toBe(1) - - await reply({ - requestID: pending[0].id, - answers: [["Option 1"]], - }) - await promise - - const after = await list() - expect(after.length).toBe(0) - }, - }) -}) - -test("reply - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await reply({ - requestID: QuestionID.make("que_unknown"), - answers: [["Option 1"]], - }) - // Should not throw - }, - }) -}) + ], + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) + + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Option 1"]], + }) + yield* Fiber.join(fiber) + + const after = yield* listEffect + expect(after.length).toBe(0) + }), + { git: true }, +) + +it.instance("reply - does nothing for unknown requestID", () => + replyEffect({ + requestID: QuestionID.make("que_unknown"), + answers: [["Option 1"]], + }), + { git: true }, +) // reject tests -test("reject - throws RejectedError", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - - const pending = await list() - await reject(pending[0].id) - - await expect(promise).rejects.toBeInstanceOf(Question.RejectedError) - }, - }) -}) - -test("reject - removes from pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }) - - const pending = await list() - expect(pending.length).toBe(1) - - await reject(pending[0].id) - promise.catch(() => {}) // Ignore rejection - - const after = await list() - expect(after.length).toBe(0) - }, - }) -}) - -test("reject - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await reject(QuestionID.make("que_unknown")) - // Should not throw - }, - }) -}) - -// multiple questions tests - -test("ask - handles multiple questions", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const questions = [ +it.instance("reject - throws RejectedError", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { question: "What would you like to do?", header: "Action", options: [ - { label: "Build", description: "Build the project" }, - { label: "Test", description: "Run tests" }, + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, ], }, + ], + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + yield* rejectEffect(pending[0].id) + + const exit = yield* Fiber.await(fiber) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError") + }), + { git: true }, +) + +it.instance("reject - removes from pending list", () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ { - question: "Which environment?", - header: "Env", + question: "What would you like to do?", + header: "Action", options: [ - { label: "Dev", description: "Development" }, - { label: "Prod", description: "Production" }, + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, ], }, - ] + ], + }).pipe(Effect.forkScoped) - const promise = ask({ - sessionID: SessionID.make("ses_test"), - questions, - }) + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) - const pending = await list() + yield* rejectEffect(pending[0].id) + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - await reply({ - requestID: pending[0].id, - answers: [["Build"], ["Dev"]], - }) + const after = yield* listEffect + expect(after.length).toBe(0) + }), + { git: true }, +) - const answers = await promise - expect(answers).toEqual([["Build"], ["Dev"]]) - }, - }) -}) +it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { git: true }) -// list tests +// multiple questions tests -test("list - returns all pending requests", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const p1 = ask({ - sessionID: SessionID.make("ses_test1"), - questions: [ - { - question: "Question 1?", - header: "Q1", - options: [{ label: "A", description: "A" }], - }, +it.instance("ask - handles multiple questions", () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Build", description: "Build the project" }, + { label: "Test", description: "Run tests" }, ], - }) - - const p2 = ask({ - sessionID: SessionID.make("ses_test2"), - questions: [ - { - question: "Question 2?", - header: "Q2", - options: [{ label: "B", description: "B" }], - }, + }, + { + question: "Which environment?", + header: "Env", + options: [ + { label: "Dev", description: "Development" }, + { label: "Prod", description: "Production" }, ], - }) - - const pending = await list() - expect(pending.length).toBe(2) - await rejectAll() - p1.catch(() => {}) - p2.catch(() => {}) - }, - }) -}) + }, + ] -test("list - returns empty when no pending", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const pending = await list() - expect(pending.length).toBe(0) - }, - }) -}) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) -test("questions stay isolated by directory", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) - - const p1 = Instance.provide({ - directory: one.path, - fn: () => - ask({ - sessionID: SessionID.make("ses_one"), - questions: [ - { - question: "Question 1?", - header: "Q1", - options: [{ label: "A", description: "A" }], - }, - ], - }), - }) + const pending = yield* waitForPending(1) - const p2 = Instance.provide({ - directory: two.path, - fn: () => - ask({ - sessionID: SessionID.make("ses_two"), - questions: [ - { - question: "Question 2?", - header: "Q2", - options: [{ label: "B", description: "B" }], - }, - ], - }), - }) + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Build"], ["Dev"]], + }) - const onePending = await Instance.provide({ - directory: one.path, - fn: () => list(), - }) - const twoPending = await Instance.provide({ - directory: two.path, - fn: () => list(), - }) - - expect(onePending.length).toBe(1) - expect(twoPending.length).toBe(1) - expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) - expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) + expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]]) + }), + { git: true }, +) - await Instance.provide({ - directory: one.path, - fn: () => reject(onePending[0].id), - }) - await Instance.provide({ - directory: two.path, - fn: () => reject(twoPending[0].id), - }) +// list tests - await p1.catch(() => {}) - await p2.catch(() => {}) -}) +it.instance("list - returns all pending requests", () => + Effect.gen(function* () { + const fiber1 = yield* askEffect({ + sessionID: SessionID.make("ses_test1"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }).pipe(Effect.forkScoped) -test("pending question rejects on instance dispose", async () => { - await using tmp = await tmpdir({ git: true }) - - const pending = Instance.provide({ - directory: tmp.path, - fn: () => { - return ask({ - sessionID: SessionID.make("ses_dispose"), - questions: [ - { - question: "Dispose me?", - header: "Dispose", - options: [{ label: "Yes", description: "Yes" }], - }, - ], - }) - }, - }) - const result = pending.then( - () => "resolved" as const, - (err) => err, - ) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const items = await list() - expect(items).toHaveLength(1) - await InstanceRuntime.disposeInstance(Instance.current) - }, - }) + const fiber2 = yield* askEffect({ + sessionID: SessionID.make("ses_test2"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(2) + expect(pending.length).toBe(2) + yield* rejectAll + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), + { git: true }, +) + +it.instance("list - returns empty when no pending", () => + Effect.gen(function* () { + const pending = yield* listEffect + expect(pending.length).toBe(0) + }), + { git: true }, +) + +it.live("questions stay isolated by directory", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped({ git: true }) + const two = yield* tmpdirScoped({ git: true }) + + const fiber1 = yield* askEffect({ + sessionID: SessionID.make("ses_one"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }).pipe(provideInstance(one), Effect.forkScoped) - expect(await result).toBeInstanceOf(Question.RejectedError) -}) + const fiber2 = yield* askEffect({ + sessionID: SessionID.make("ses_two"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }).pipe(provideInstance(two), Effect.forkScoped) + + const onePending = yield* waitForPending(1).pipe(provideInstance(one)) + const twoPending = yield* waitForPending(1).pipe(provideInstance(two)) + + expect(onePending.length).toBe(1) + expect(twoPending.length).toBe(1) + expect(onePending[0].sessionID).toBe(SessionID.make("ses_one")) + expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two")) + + yield* rejectEffect(onePending[0].id).pipe(provideInstance(one)) + yield* rejectEffect(twoPending[0].id).pipe(provideInstance(two)) + + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), +) + +it.live("pending question rejects on instance dispose", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_dispose"), + questions: [ + { + question: "Dispose me?", + header: "Dispose", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }).pipe(provideInstance(dir), Effect.forkScoped) + + expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) + yield* Effect.promise(() => + Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + ) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError) + }), +) + +it.live("pending question rejects on instance reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_reload"), + questions: [ + { + question: "Reload me?", + header: "Reload", + options: [{ label: "Yes", description: "Yes" }], + }, + ], + }).pipe(provideInstance(dir), Effect.forkScoped) -test("pending question rejects on instance reload", async () => { - await using tmp = await tmpdir({ git: true }) - - const pending = Instance.provide({ - directory: tmp.path, - fn: () => { - return ask({ - sessionID: SessionID.make("ses_reload"), - questions: [ - { - question: "Reload me?", - header: "Reload", - options: [{ label: "Yes", description: "Yes" }], - }, - ], - }) - }, - }) - const result = pending.then( - () => "resolved" as const, - (err) => err, - ) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const items = await list() - expect(items).toHaveLength(1) - await InstanceRuntime.reloadInstance({ directory: tmp.path }) - }, - }) + expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) + yield* Effect.promise(() => reloadTestInstance({ directory: dir })) - expect(await result).toBeInstanceOf(Question.RejectedError) -}) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Question.RejectedError) + }), +) diff --git a/packages/opencode/test/server/global-bus.ts b/packages/opencode/test/server/global-bus.ts new file mode 100644 index 0000000000..c8d0f92191 --- /dev/null +++ b/packages/opencode/test/server/global-bus.ts @@ -0,0 +1,34 @@ +import { GlobalBus, type GlobalEvent } from "@/bus/global" +import { Cause, Effect } from "effect" + +export function waitGlobalBusEvent(input: { + timeout?: number + message?: string + predicate: (event: GlobalEvent) => boolean +}) { + return Effect.callback((resume) => { + const cleanup = () => GlobalBus.off("event", handler) + + const handler = (event: GlobalEvent) => { + try { + if (!input.predicate(event)) return + cleanup() + resume(Effect.succeed(event)) + } catch (error) { + cleanup() + resume(Effect.fail(error)) + } + } + + GlobalBus.on("event", handler) + return Effect.sync(cleanup) + }).pipe( + Effect.timeout(input.timeout ?? 10_000), + Effect.mapError((error) => + Cause.isTimeoutError(error) ? new Error(input.message ?? "timed out waiting for global bus event") : error, + ), + ) +} + +export const waitGlobalBusEventPromise = (input: Parameters[0]) => + Effect.runPromise(waitGlobalBusEvent(input)) diff --git a/packages/opencode/test/server/httpapi-config.test.ts b/packages/opencode/test/server/httpapi-config.test.ts index 7d269b6bed..16e8975ea1 100644 --- a/packages/opencode/test/server/httpapi-config.test.ts +++ b/packages/opencode/test/server/httpapi-config.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" import path from "path" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -18,20 +17,9 @@ function app() { } async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, }) } diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 0185af2df9..5f36a32746 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" @@ -11,6 +10,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Worktree } from "../../src/worktree" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -31,20 +31,9 @@ function createSession(input?: Session.CreateInput) { } async function waitReady(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for worktree.ready")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for worktree.ready", + predicate: (event) => event.payload.type === Worktree.Event.Ready.type && event.directory === directory, }) } diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index f311de2b4a..7a889aea04 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -1,6 +1,5 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" import { describe, expect } from "bun:test" import { Effect, Fiber, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http" @@ -19,6 +18,7 @@ import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpa import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" +import { waitGlobalBusEvent } from "./global-bus" import { testEffect } from "../lib/effect" const testStateLayer = Layer.effectDiscard( @@ -95,24 +95,10 @@ const serveProbe = (probePath: HttpRouter.PathInput = "/probe") => Layer.build, ) -const waitDisposedEvent = Effect.promise( - () => - new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed") return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve({ directory: event.directory, workspace: event.workspace }) - } - - GlobalBus.on("event", onEvent) - }), -) +const waitDisposedEvent = waitGlobalBusEvent({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", +}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace }))) const serveDisposeProbe = () => HttpRouter.serve( diff --git a/packages/opencode/test/server/httpapi-instance.legacy.test.ts b/packages/opencode/test/server/httpapi-instance.legacy.test.ts index 22a56ba8a4..b5f0805e4c 100644 --- a/packages/opencode/test/server/httpapi-instance.legacy.test.ts +++ b/packages/opencode/test/server/httpapi-instance.legacy.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "@/bus/global" -import { Instance } from "../../src/project/instance" import { Server } from "../../src/server/server" import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance" import * as Log from "@opencode-ai/core/util/log" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -18,20 +17,9 @@ function app() { } async function waitDisposed(directory: string) { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - GlobalBus.off("event", onEvent) - reject(new Error("timed out waiting for instance disposal")) - }, 10_000) - - function onEvent(event: { directory?: string; payload: { type?: string } }) { - if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return - clearTimeout(timer) - GlobalBus.off("event", onEvent) - resolve() - } - - GlobalBus.on("event", onEvent) + await waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory, }) } @@ -117,13 +105,9 @@ describe("instance HttpApi", () => { test("serves instance dispose through Hono bridge", async () => { await using tmp = await tmpdir() - const disposed = new Promise((resolve) => { - const onEvent = (event: { directory?: string; payload: { type?: string } }) => { - if (event.payload.type !== "server.instance.disposed") return - GlobalBus.off("event", onEvent) - resolve(event.directory) - } - GlobalBus.on("event", onEvent) + const disposed = waitGlobalBusEventPromise({ + message: "timed out waiting for instance disposal", + predicate: (event) => event.payload.type === "server.instance.disposed", }) const response = await app().request(InstancePaths.dispose, { @@ -133,6 +117,6 @@ describe("instance HttpApi", () => { expect(response.status).toBe(200) expect(await response.json()).toBe(true) - expect(await disposed).toBe(tmp.path) + expect((await disposed).directory).toBe(tmp.path) }) }) diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 1fd3ce2b39..1b9e1c1503 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import type { Context } from "hono" import { Flag } from "@opencode-ai/core/flag/flag" -import { GlobalBus } from "../../src/bus/global" import { TuiEvent } from "../../src/cli/cmd/tui/event" import { SessionID } from "../../src/session/schema" import { Instance } from "../../src/project/instance" @@ -12,6 +11,7 @@ import * as Log from "@opencode-ai/core/util/log" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { waitGlobalBusEventPromise } from "./global-bus" void Log.init({ print: false }) @@ -23,14 +23,9 @@ function app(experimental = true) { } function nextCommandExecute() { - return new Promise((resolve) => { - const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => { - if (event.payload.type !== TuiEvent.CommandExecute.type) return - GlobalBus.off("event", listener) - resolve(event.payload.properties?.command) - } - GlobalBus.on("event", listener) - }) + return waitGlobalBusEventPromise({ + predicate: (event) => event.payload.type === TuiEvent.CommandExecute.type, + }).then((event) => event.payload.properties?.command) } async function expectTrue(path: string, headers: Record, body?: unknown) { diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 028436d295..94f401afd8 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -8,7 +8,7 @@ import { Ripgrep } from "../../src/file/ripgrep" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" import { Agent } from "../../src/agent/agent" -import { provideTmpdirInstance } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const it = testEffect( @@ -33,49 +33,47 @@ const ctx = { } describe("tool.glob", () => { - it.live("matches files from a directory path", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n")) - yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n")) - const info = yield* GlobTool - const glob = yield* info.init() - const result = yield* glob.execute( + it.instance("matches files from a directory path", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\n")) + yield* Effect.promise(() => Bun.write(path.join(test.directory, "b.txt"), "hello\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const result = yield* glob.execute( + { + pattern: "*.ts", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain(path.join(test.directory, "a.ts")) + expect(result.output).not.toContain(path.join(test.directory, "b.txt")) + }), + ) + + it.instance("rejects exact file paths", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "a.ts") + yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const exit = yield* glob + .execute( { pattern: "*.ts", - path: dir, + path: file, }, ctx, ) - expect(result.metadata.count).toBe(1) - expect(result.output).toContain(path.join(dir, "a.ts")) - expect(result.output).not.toContain(path.join(dir, "b.txt")) - }), - ), - ) - - it.live("rejects exact file paths", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "a.ts") - yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) - const info = yield* GlobTool - const glob = yield* info.init() - const exit = yield* glob - .execute( - { - pattern: "*.ts", - path: file, - }, - ctx, - ) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - const err = Cause.squash(exit.cause) - expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") - } - }), - ), + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") + } + }), ) }) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index c807d12812..4b0da7c698 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import path from "path" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" -import { provideInstance, provideTmpdirInstance } from "../fixture/fixture" +import { provideInstance, TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" @@ -54,61 +54,58 @@ describe("tool.grep", () => { }), ) - it.live("no matches returns correct output", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "xyznonexistentpatternxyz123", - path: dir, - }, - ctx, - ) - expect(result.metadata.matches).toBe(0) - expect(result.output).toBe("No files found") - }), - ), + it.instance("no matches returns correct output", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "xyznonexistentpatternxyz123", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.matches).toBe(0) + expect(result.output).toBe("No files found") + }), ) - it.live("finds matches in tmp instance", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "line", - path: dir, - }, - ctx, - ) - expect(result.metadata.matches).toBeGreaterThan(0) - }), - ), + it.instance("finds matches in tmp instance", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line", + path: test.directory, + }, + ctx, + ) + expect(result.metadata.matches).toBeGreaterThan(0) + }), ) - it.live("supports exact file paths", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const file = path.join(dir, "test.txt") - yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) - const info = yield* GrepTool - const grep = yield* info.init() - const result = yield* grep.execute( - { - pattern: "line2", - path: file, - }, - ctx, - ) - expect(result.metadata.matches).toBe(1) - expect(result.output).toContain(file) - expect(result.output).toContain("Line 2: line2") - }), - ), + it.instance("supports exact file paths", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "test.txt") + yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line2", + path: file, + }, + ctx, + ) + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain(file) + expect(result.output).toContain("Line 2: line2") + }), ) }) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 662073a8c3..3f2cba8941 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -6,7 +6,6 @@ import { SessionID, MessageID } from "../../src/session/schema" import { Agent } from "../../src/agent/agent" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" -import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -34,56 +33,52 @@ const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Quest }) describe("tool.question", () => { - it.live("should successfully execute with valid question parameters", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const question = yield* Question.Service - const toolInfo = yield* QuestionTool - const tool = yield* toolInfo.init() - const questions = [ - { - question: "What is your favorite color?", - header: "Color", - options: [ - { label: "Red", description: "The color of passion" }, - { label: "Blue", description: "The color of sky" }, - ], - multiple: false, - }, - ] + it.instance("should successfully execute with valid question parameters", () => + Effect.gen(function* () { + const question = yield* Question.Service + const toolInfo = yield* QuestionTool + const tool = yield* toolInfo.init() + const questions = [ + { + question: "What is your favorite color?", + header: "Color", + options: [ + { label: "Red", description: "The color of passion" }, + { label: "Blue", description: "The color of sky" }, + ], + multiple: false, + }, + ] - const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) - const item = yield* pending(question) - yield* question.reply({ requestID: item.id, answers: [["Red"]] }) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) + const item = yield* pending(question) + yield* question.reply({ requestID: item.id, answers: [["Red"]] }) - const result = yield* Fiber.join(fiber) - expect(result.title).toBe("Asked 1 question") - }), - ), + const result = yield* Fiber.join(fiber) + expect(result.title).toBe("Asked 1 question") + }), ) - it.live("should now pass with a header longer than 12 but less than 30 chars", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const question = yield* Question.Service - const toolInfo = yield* QuestionTool - const tool = yield* toolInfo.init() - const questions = [ - { - question: "What is your favorite animal?", - header: "This Header is Over 12", - options: [{ label: "Dog", description: "Man's best friend" }], - }, - ] + it.instance("should now pass with a header longer than 12 but less than 30 chars", () => + Effect.gen(function* () { + const question = yield* Question.Service + const toolInfo = yield* QuestionTool + const tool = yield* toolInfo.init() + const questions = [ + { + question: "What is your favorite animal?", + header: "This Header is Over 12", + options: [{ label: "Dog", description: "Man's best friend" }], + }, + ] - const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) - const item = yield* pending(question) - yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) + const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped) + const item = yield* pending(question) + yield* question.reply({ requestID: item.id, answers: [["Dog"]] }) - const result = yield* Fiber.join(fiber) - expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) - }), - ), + const result = yield* Fiber.join(fiber) + expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) + }), ) // intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 3fa61401e1..695d96ec2f 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -13,7 +13,7 @@ import { ReadTool } from "../../src/tool/read" import { Truncate } from "@/tool/truncate" import { Tool } from "@/tool/tool" import { Filesystem } from "@/util/filesystem" -import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -255,28 +255,28 @@ describe("tool.read env file permissions", () => { }) describe("tool.read truncation", () => { - it.live("truncates large file by bytes and sets truncated metadata", () => + it.instance("truncates large file by bytes and sets truncated metadata", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() + const test = yield* TestInstance const base = yield* load(path.join(FIXTURES_DIR, "models-api.json")) const target = 60 * 1024 const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length)) - yield* put(path.join(dir, "large.json"), content) + yield* put(path.join(test.directory, "large.json"), content) - const result = yield* exec(dir, { filePath: path.join(dir, "large.json") }) + const result = yield* run({ filePath: path.join(test.directory, "large.json") }) expect(result.metadata.truncated).toBe(true) expect(result.output).toContain("Output capped at") expect(result.output).toContain("Use offset=") }), ) - it.live("truncates by line count when limit is specified", () => + it.instance("truncates by line count when limit is specified", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() + const test = yield* TestInstance const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") - yield* put(path.join(dir, "many-lines.txt"), lines) + yield* put(path.join(test.directory, "many-lines.txt"), lines) - const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 }) + const result = yield* run({ filePath: path.join(test.directory, "many-lines.txt"), limit: 10 }) expect(result.metadata.truncated).toBe(true) expect(result.output).toContain("Showing lines 1-10 of 100") expect(result.output).toContain("Use offset=11") @@ -286,12 +286,12 @@ describe("tool.read truncation", () => { }), ) - it.live("does not truncate small file", () => + it.instance("does not truncate small file", () => Effect.gen(function* () { - const dir = yield* tmpdirScoped() - yield* put(path.join(dir, "small.txt"), "hello world") + const test = yield* TestInstance + yield* put(path.join(test.directory, "small.txt"), "hello world") - const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") }) + const result = yield* run({ filePath: path.join(test.directory, "small.txt") }) expect(result.metadata.truncated).toBe(false) expect(result.output).toContain("End of file") }), diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index f9ac07831a..c33981ddff 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -2,10 +2,9 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" import { Effect, Layer } from "effect" -import { Instance } from "../../src/project/instance" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ToolRegistry } from "@/tool/registry" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -57,136 +56,133 @@ afterEach(async () => { }) describe("tool.registry", () => { - it.live("loads tools from .opencode/tool (singular)", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tool = path.join(opencode, "tool") - yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tool, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("hello") - }), - ), + it.instance("loads tools from .opencode/tool (singular)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tool = path.join(opencode, "tool") + yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tool, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("hello") + }), ) - it.live("loads tools from .opencode/tools (plural)", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("hello") - }), - ), + it.instance("loads tools from .opencode/tools (plural)", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("hello") + }), ) - it.live("loads tools with external dependencies without crashing", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const opencode = path.join(dir, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package.json"), - JSON.stringify({ - name: "custom-tools", - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, - }), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package-lock.json"), - JSON.stringify({ - name: "custom-tools", - lockfileVersion: 3, - packages: { - "": { - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, + it.instance("loads tools with external dependencies without crashing", () => + Effect.gen(function* () { + const test = yield* TestInstance + const opencode = path.join(test.directory, ".opencode") + const tools = path.join(opencode, "tools") + yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package.json"), + JSON.stringify({ + name: "custom-tools", + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", + }, + }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(opencode, "package-lock.json"), + JSON.stringify({ + name: "custom-tools", + lockfileVersion: 3, + packages: { + "": { + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", }, }, - }), - ), - ) + }, + }), + ), + ) - const cowsay = path.join(opencode, "node_modules", "cowsay") - yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "package.json"), - JSON.stringify({ - name: "cowsay", - type: "module", - exports: "./index.js", - }), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "index.js"), - ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), - ), - ) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "cowsay.ts"), - [ - "import { say } from 'cowsay'", - "export default {", - " description: 'tool that imports cowsay at top level',", - " args: { text: { type: 'string' } },", - " execute: async ({ text }: { text: string }) => {", - " return say({ text })", - " },", - "}", - "", - ].join("\n"), - ), - ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() - expect(ids).toContain("cowsay") - }), - ), + const cowsay = path.join(opencode, "node_modules", "cowsay") + yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "package.json"), + JSON.stringify({ + name: "cowsay", + type: "module", + exports: "./index.js", + }), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(cowsay, "index.js"), + ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(tools, "cowsay.ts"), + [ + "import { say } from 'cowsay'", + "export default {", + " description: 'tool that imports cowsay at top level',", + " args: { text: { type: 'string' } },", + " execute: async ({ text }: { text: string }) => {", + " return say({ text })", + " },", + "}", + "", + ].join("\n"), + ), + ) + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + expect(ids).toContain("cowsay") + }), ) }) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 4931d2a544..8bba52a4b2 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool" import { Agent } from "../../src/agent/agent" import { SessionID, MessageID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances, provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" const ctx = { @@ -58,66 +58,79 @@ const run = Effect.fn("WriteToolTest.run")(function* ( describe("tool.write", () => { describe("new file creation", () => { - it.live("writes content to new file", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "newfile.txt") - const result = yield* run({ filePath: filepath, content: "Hello, World!" }) - - expect(result.output).toContain("Wrote file successfully") - expect(result.metadata.exists).toBe(false) - - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("Hello, World!") - }), - ), + it.instance("writes content to new file", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "newfile.txt") + const result = yield* run({ filePath: filepath, content: "Hello, World!" }) + + expect(result.output).toContain("Wrote file successfully") + expect(result.metadata.exists).toBe(false) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("Hello, World!") + }), ) - it.live("creates parent directories if needed", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "nested", "deep", "file.txt") - yield* run({ filePath: filepath, content: "nested content" }) + it.instance("creates parent directories if needed", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "nested", "deep", "file.txt") + yield* run({ filePath: filepath, content: "nested content" }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("nested content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("nested content") + }), ) - it.live("handles relative paths by resolving to instance directory", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* run({ filePath: "relative.txt", content: "relative content" }) + it.instance("handles relative paths by resolving to instance directory", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* run({ filePath: "relative.txt", content: "relative content" }) - const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8")) - expect(content).toBe("relative content") - }), - ), + const content = yield* Effect.promise(() => fs.readFile(path.join(test.directory, "relative.txt"), "utf-8")) + expect(content).toBe("relative content") + }), ) }) describe("existing file overwrite", () => { - it.live("overwrites existing file content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "existing.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) - const result = yield* run({ filePath: filepath, content: "new content" }) + it.instance("overwrites existing file content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8")) + const result = yield* run({ filePath: filepath, content: "new content" }) + + expect(result.output).toContain("Wrote file successfully") + expect(result.metadata.exists).toBe(true) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("new content") + }), + ) - expect(result.output).toContain("Wrote file successfully") - expect(result.metadata.exists).toBe(true) + it.instance("preserves BOM when overwriting existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("new content") - }), - ), + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), ) - it.live("preserves BOM when overwriting existing files", () => - provideTmpdirInstance((dir) => + it.instance( + "restores BOM after formatter strips it", + () => Effect.gen(function* () { - const filepath = path.join(dir, "existing.cs") + const test = yield* TestInstance + const filepath = path.join(test.directory, "formatted.cs") const bom = String.fromCharCode(0xfeff) yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) @@ -127,165 +140,138 @@ describe("tool.write", () => { expect(content.charCodeAt(0)).toBe(0xfeff) expect(content.slice(1)).toBe("using Up;\n") }), - ), - ) - - it.live("restores BOM after formatter strips it", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "formatted.cs") - const bom = String.fromCharCode(0xfeff) - yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) - - yield* run({ filePath: filepath, content: "using Up;\n" }) - - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content.charCodeAt(0)).toBe(0xfeff) - expect(content.slice(1)).toBe("using Up;\n") - }), - { - config: { - formatter: { - stripbom: { - extensions: [".cs"], - command: [ - "node", - "-e", - "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", - "$FILE", - ], - }, + { + config: { + formatter: { + stripbom: { + extensions: [".cs"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", + "$FILE", + ], }, }, }, - ), + }, ) - it.live("returns diff in metadata for existing files", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "file.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) - const result = yield* run({ filePath: filepath, content: "new" }) + it.instance("returns diff in metadata for existing files", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "file.txt") + yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8")) + const result = yield* run({ filePath: filepath, content: "new" }) - expect(result.metadata).toHaveProperty("filepath", filepath) - expect(result.metadata).toHaveProperty("exists", true) - }), - ), + expect(result.metadata).toHaveProperty("filepath", filepath) + expect(result.metadata).toHaveProperty("exists", true) + }), ) }) describe("file permissions", () => { - it.live("sets file permissions when writing sensitive data", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "sensitive.json") - yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) }) + it.instance("sets file permissions when writing sensitive data", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "sensitive.json") + yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) }) - if (process.platform !== "win32") { - const stats = yield* Effect.promise(() => fs.stat(filepath)) - expect(stats.mode & 0o777).toBe(0o644) - } - }), - ), + if (process.platform !== "win32") { + const stats = yield* Effect.promise(() => fs.stat(filepath)) + expect(stats.mode & 0o777).toBe(0o644) + } + }), ) }) describe("content types", () => { - it.live("writes JSON content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "data.json") - const data = { key: "value", nested: { array: [1, 2, 3] } } - yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) }) - - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(JSON.parse(content)).toEqual(data) - }), - ), + it.instance("writes JSON content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "data.json") + const data = { key: "value", nested: { array: [1, 2, 3] } } + yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(JSON.parse(content)).toEqual(data) + }), ) - it.live("writes binary-safe content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "binary.bin") - const content = "Hello\x00World\x01\x02\x03" - yield* run({ filePath: filepath, content }) + it.instance("writes binary-safe content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "binary.bin") + const content = "Hello\x00World\x01\x02\x03" + yield* run({ filePath: filepath, content }) - const buf = yield* Effect.promise(() => fs.readFile(filepath)) - expect(buf.toString()).toBe(content) - }), - ), + const buf = yield* Effect.promise(() => fs.readFile(filepath)) + expect(buf.toString()).toBe(content) + }), ) - it.live("writes empty content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "empty.txt") - yield* run({ filePath: filepath, content: "" }) + it.instance("writes empty content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "empty.txt") + yield* run({ filePath: filepath, content: "" }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe("") + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe("") - const stats = yield* Effect.promise(() => fs.stat(filepath)) - expect(stats.size).toBe(0) - }), - ), + const stats = yield* Effect.promise(() => fs.stat(filepath)) + expect(stats.size).toBe(0) + }), ) - it.live("writes multi-line content", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "multiline.txt") - const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") - yield* run({ filePath: filepath, content: lines }) + it.instance("writes multi-line content", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "multiline.txt") + const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n") + yield* run({ filePath: filepath, content: lines }) - const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) - expect(content).toBe(lines) - }), - ), + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content).toBe(lines) + }), ) - it.live("handles different line endings", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "crlf.txt") - const content = "Line 1\r\nLine 2\r\nLine 3" - yield* run({ filePath: filepath, content }) + it.instance("handles different line endings", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "crlf.txt") + const content = "Line 1\r\nLine 2\r\nLine 3" + yield* run({ filePath: filepath, content }) - const buf = yield* Effect.promise(() => fs.readFile(filepath)) - expect(buf.toString()).toBe(content) - }), - ), + const buf = yield* Effect.promise(() => fs.readFile(filepath)) + expect(buf.toString()).toBe(content) + }), ) }) describe("error handling", () => { - it.live("throws error when OS denies write access", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const readonlyPath = path.join(dir, "readonly.txt") - yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) - yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) - const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) - expect(exit._tag).toBe("Failure") - }), - ), + it.instance("throws error when OS denies write access", () => + Effect.gen(function* () { + const test = yield* TestInstance + const readonlyPath = path.join(test.directory, "readonly.txt") + yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8")) + yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444)) + const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit) + expect(exit._tag).toBe("Failure") + }), ) }) describe("title generation", () => { - it.live("returns relative path as title", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const filepath = path.join(dir, "src", "components", "Button.tsx") - yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) - - const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) - expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) - }), - ), + it.instance("returns relative path as title", () => + Effect.gen(function* () { + const test = yield* TestInstance + const filepath = path.join(test.directory, "src", "components", "Button.tsx") + yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true })) + + const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" }) + expect(result.title).toEndWith(path.join("src", "components", "Button.tsx")) + }), ) }) }) From a6464062b7b28a3b0e0637166c73eadef1ebe878 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 00:32:24 +0000 Subject: [PATCH 062/178] chore: generate --- packages/opencode/script/httpapi-exercise.ts | 931 ++++++++++++------ packages/opencode/test/lib/effect.ts | 18 +- .../opencode/test/question/question.test.ts | 426 ++++---- 3 files changed, 857 insertions(+), 518 deletions(-) diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index f0faa27602..1681f2e212 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -32,7 +32,9 @@ import type { Project } from "../src/project/project" import path from "path" const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL -const exerciseGlobalRoot = process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) +const exerciseGlobalRoot = + process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`) process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data") process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config") process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state") @@ -42,7 +44,9 @@ const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencod const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode") const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB -const exerciseDatabasePath = process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) +const exerciseDatabasePath = + process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? + path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) process.env.OPENCODE_DB = exerciseDatabasePath Flag.OPENCODE_DB = exerciseDatabasePath @@ -167,21 +171,21 @@ const original = { } type Runtime = { - PublicApi: typeof import("../src/server/routes/instance/httpapi/public")["PublicApi"] - ExperimentalHttpApiServer: typeof import("../src/server/routes/instance/httpapi/server")["ExperimentalHttpApiServer"] - Server: typeof import("../src/server/server")["Server"] - AppLayer: typeof import("../src/effect/app-runtime")["AppLayer"] - InstanceRef: typeof import("../src/effect/instance-ref")["InstanceRef"] - Instance: typeof import("../src/project/instance")["Instance"] - InstanceStore: typeof import("../src/project/instance-store")["InstanceStore"] - Session: typeof import("../src/session/session")["Session"] - Todo: typeof import("../src/session/todo")["Todo"] - Worktree: typeof import("../src/worktree")["Worktree"] - Project: typeof import("../src/project/project")["Project"] + PublicApi: (typeof import("../src/server/routes/instance/httpapi/public"))["PublicApi"] + ExperimentalHttpApiServer: (typeof import("../src/server/routes/instance/httpapi/server"))["ExperimentalHttpApiServer"] + Server: (typeof import("../src/server/server"))["Server"] + AppLayer: (typeof import("../src/effect/app-runtime"))["AppLayer"] + InstanceRef: (typeof import("../src/effect/instance-ref"))["InstanceRef"] + Instance: (typeof import("../src/project/instance"))["Instance"] + InstanceStore: (typeof import("../src/project/instance-store"))["InstanceStore"] + Session: (typeof import("../src/session/session"))["Session"] + Todo: (typeof import("../src/session/todo"))["Todo"] + Worktree: (typeof import("../src/worktree"))["Worktree"] + Project: (typeof import("../src/project/project"))["Project"] Tui: typeof import("../src/server/routes/instance/tui") - disposeAllInstances: typeof import("../test/fixture/fixture")["disposeAllInstances"] - tmpdir: typeof import("../test/fixture/fixture")["tmpdir"] - resetDatabase: typeof import("../test/fixture/db")["resetDatabase"] + disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"] + tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"] + resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"] } let runtimePromise: Promise | undefined @@ -276,7 +280,11 @@ class ScenarioBuilder { ) } - status(status = 200, inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, compare: Comparison = "status") { + status( + status = 200, + inspect?: (ctx: SeededContext, result: CallResult) => Effect.Effect, + compare: Comparison = "status", + ) { return this.done(compare, (ctx, result) => Effect.gen(function* () { if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) @@ -287,19 +295,20 @@ class ScenarioBuilder { /** Assert JSON status/content-type plus an optional synchronous body check. */ json(status = 200, inspect?: (body: unknown, ctx: SeededContext) => void, compare: Comparison = "json") { - return this.jsonEffect( - status, - inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, - compare, - ) + return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare) } /** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */ - jsonEffect(status = 200, inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, compare: Comparison = "json") { + jsonEffect( + status = 200, + inspect?: (body: unknown, ctx: SeededContext) => Effect.Effect, + compare: Comparison = "json", + ) { return this.done(compare, (ctx, result) => Effect.gen(function* () { if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`) - if (!looksJson(result)) throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) + if (!looksJson(result)) + throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`) if (inspect) yield* inspect(result.body, ctx) }), ) @@ -321,7 +330,10 @@ class ScenarioBuilder { return builder } - private done(compare: Comparison, expect: (ctx: SeededContext, result: CallResult) => Effect.Effect): ActiveScenario { + private done( + compare: Comparison, + expect: (ctx: SeededContext, result: CallResult) => Effect.Effect, + ): ActiveScenario { const state = this.state return { kind: "active", @@ -357,52 +369,80 @@ const pending = (method: Method, path: string, name: string, reason: string): To }) function route(template: string, params: Record) { - return Object.entries(params).reduce((next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), template) + return Object.entries(params).reduce( + (next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value), + template, + ) } const scenarios: Scenario[] = [ - http.get("/global/health", "global.health").global().json(200, (body) => { - object(body) - check(body.healthy === true, "server should report healthy") - }), + http + .get("/global/health", "global.health") + .global() + .json(200, (body) => { + object(body) + check(body.healthy === true, "server should report healthy") + }), http .get("/global/event", "global.event") .global() .stream() - .status(200, (_ctx, result) => - Effect.sync(() => { - check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") - check(result.text.includes("server.connected"), "global event should emit initial connection event") - }), - "status"), + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "global event should be an SSE stream") + check(result.text.includes("server.connected"), "global event should emit initial connection event") + }), + "status", + ), http.get("/global/config", "global.config.get").global().json(), http .patch("/global/config", "global.config.update") .global() .seeded(() => Effect.promise(() => - Bun.write(path.join(exerciseConfigDirectory, "opencode.jsonc"), JSON.stringify({ username: "httpapi-global" }, null, 2)), + Bun.write( + path.join(exerciseConfigDirectory, "opencode.jsonc"), + JSON.stringify({ username: "httpapi-global" }, null, 2), + ), ), ) .at(() => ({ path: "/global/config", body: { username: "httpapi-global" } })) - .jsonEffect(200, (body) => - Effect.gen(function* () { - object(body) - check(body.username === "httpapi-global", "global config update should return patched config") - const text = yield* Effect.promise(() => Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text()) - check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") - }), - "status"), - http.post("/global/dispose", "global.dispose").global().mutating().json(200, (body) => { - check(body === true, "global dispose should return true") - }, "status"), + .jsonEffect( + 200, + (body) => + Effect.gen(function* () { + object(body) + check(body.username === "httpapi-global", "global config update should return patched config") + const text = yield* Effect.promise(() => + Bun.file(path.join(exerciseConfigDirectory, "opencode.jsonc")).text(), + ) + check(text.includes('"username": "httpapi-global"'), "global config update should write isolated config file") + }), + "status", + ), + http + .post("/global/dispose", "global.dispose") + .global() + .mutating() + .json( + 200, + (body) => { + check(body === true, "global dispose should return true") + }, + "status", + ), http.get("/path", "path.get").json(200, (body, ctx) => { object(body) check(body.directory === ctx.directory, "directory should resolve from x-opencode-directory") check(body.worktree === ctx.directory, "worktree should resolve from x-opencode-directory") }), http.get("/vcs", "vcs.get").json(), - http.get("/vcs/diff", "vcs.diff").at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })).json(200, array), + http + .get("/vcs/diff", "vcs.diff") + .at((ctx) => ({ path: "/vcs/diff?mode=git", headers: ctx.headers() })) + .json(200, array), http.get("/command", "command.list").json(200, array, "status"), http.get("/agent", "app.agents").json(200, array, "status"), http.get("/skill", "app.skills").json(200, array, "status"), @@ -413,20 +453,28 @@ const scenarios: Scenario[] = [ .patch("/config", "config.update") .mutating() .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: "httpapi-local" } })) - .json(200, (body) => { - object(body) - check(body.username === "httpapi-local", "local config update should return patched config") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(body.username === "httpapi-local", "local config update should return patched config") + }, + "status", + ), http .patch("/config", "config.update.invalid") .at((ctx) => ({ path: "/config", headers: ctx.headers(), body: { username: 1 } })) .status(400), http.get("/config/providers", "config.providers").json(), http.get("/project", "project.list").json(200, array, "status"), - http.get("/project/current", "project.current").json(200, (body, ctx) => { - object(body) - check(body.worktree === ctx.directory, "current project should resolve from scenario directory") - }, "status"), + http.get("/project/current", "project.current").json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "current project should resolve from scenario directory") + }, + "status", + ), http .patch("/project/{projectID}", "project.update") .mutating() @@ -436,55 +484,93 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { name: "HTTP API Project", commands: { start: "bun --version" } }, })) - .json(200, (body) => { - object(body) - check(body.name === "HTTP API Project", "project update should return patched name") - check(isRecord(body.commands) && body.commands.start === "bun --version", "project update should return patched command") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(body.name === "HTTP API Project", "project update should return patched name") + check( + isRecord(body.commands) && body.commands.start === "bun --version", + "project update should return patched command", + ) + }, + "status", + ), http .post("/project/git/init", "project.initGit") .mutating() .inProject({ git: false }) - .json(200, (body, ctx) => { - object(body) - check(body.worktree === ctx.directory, "git init should return current project") - check(body.vcs === "git", "git init should mark the project as git-backed") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.worktree === ctx.directory, "git init should return current project") + check(body.vcs === "git", "git init should mark the project as git-backed") + }, + "status", + ), http.get("/provider", "provider.list").json(), http.get("/provider/auth", "provider.auth").json(), http .post("/provider/{providerID}/oauth/authorize", "provider.oauth.authorize") - .at((ctx) => ({ path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/authorize", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) .status(400), http .post("/provider/{providerID}/oauth/callback", "provider.oauth.callback") - .at((ctx) => ({ path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), headers: ctx.headers(), body: { method: "bad" } })) + .at((ctx) => ({ + path: route("/provider/{providerID}/oauth/callback", { providerID: "httpapi" }), + headers: ctx.headers(), + body: { method: "bad" }, + })) .status(400), http.get("/permission", "permission.list").json(200, array), http .post("/permission/{requestID}/reply", "permission.reply.invalid") - .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "bad" } })) + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "bad" }, + })) .status(400), http .post("/permission/{requestID}/reply", "permission.reply") - .at((ctx) => ({ path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), headers: ctx.headers(), body: { reply: "once" } })) + .at((ctx) => ({ + path: route("/permission/{requestID}/reply", { requestID: "per_httpapi" }), + headers: ctx.headers(), + body: { reply: "once" }, + })) .json(200, (body) => { check(body === true, "permission reply should return true even when request is no longer pending") }), http.get("/question", "question.list").json(200, array), http .post("/question/{requestID}/reply", "question.reply.invalid") - .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: "Yes" } })) + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: "Yes" }, + })) .status(400), http .post("/question/{requestID}/reply", "question.reply") - .at((ctx) => ({ path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), headers: ctx.headers(), body: { answers: [["Yes"]] } })) + .at((ctx) => ({ + path: route("/question/{requestID}/reply", { requestID: "que_httpapi_reply" }), + headers: ctx.headers(), + body: { answers: [["Yes"]] }, + })) .json(200, (body) => { check(body === true, "question reply should return true even when request is no longer pending") }), http .post("/question/{requestID}/reject", "question.reject") - .at((ctx) => ({ path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/question/{requestID}/reject", { requestID: "que_httpapi_reject" }), + headers: ctx.headers(), + })) .json(200, (body) => { check(body === true, "question reject should return true even when request is no longer pending") }), @@ -517,7 +603,10 @@ const scenarios: Scenario[] = [ http .get("/find/file", "find.files") .seeded((ctx) => ctx.file("hello.txt", "hello\n")) - .at((ctx) => ({ path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, headers: ctx.headers() })) + .at((ctx) => ({ + path: `/find/file?${new URLSearchParams({ query: "hello", dirs: "false" })}`, + headers: ctx.headers(), + })) .json(200, array), http .get("/find/symbol", "find.symbols") @@ -527,12 +616,15 @@ const scenarios: Scenario[] = [ http .get("/event", "event.stream") .stream() - .status(200, (_ctx, result) => - Effect.sync(() => { - check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") - check(result.text.includes("server.connected"), "event should emit initial connection event") - }), - "status"), + .status( + 200, + (_ctx, result) => + Effect.sync(() => { + check(result.contentType.includes("text/event-stream"), "event should be an SSE stream") + check(result.text.includes("server.connected"), "event should emit initial connection event") + }), + "status", + ), http.get("/mcp", "mcp.status").json(), http .post("/mcp", "mcp.add") @@ -542,22 +634,34 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { name: "httpapi-disabled", config: { type: "local", command: ["bun", "--version"], enabled: false } }, })) - .json(200, (body) => { - object(body) - object(body["httpapi-disabled"]) - check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") - }, "status"), + .json( + 200, + (body) => { + object(body) + object(body["httpapi-disabled"]) + check(body["httpapi-disabled"].status === "disabled", "disabled MCP server should be added without spawning") + }, + "status", + ), http .post("/mcp", "mcp.add.invalid") - .at((ctx) => ({ path: "/mcp", headers: ctx.headers(), body: { name: "httpapi-invalid", config: { type: "invalid" } } })) + .at((ctx) => ({ + path: "/mcp", + headers: ctx.headers(), + body: { name: "httpapi-invalid", config: { type: "invalid" } }, + })) .status(400), http .post("/mcp/{name}/auth", "mcp.auth.start") .at((ctx) => ({ path: route("/mcp/{name}/auth", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(400, (body) => { - object(body) - check(typeof body.error === "string", "unsupported MCP OAuth response should include error") - }, "status"), + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth response should include error") + }, + "status", + ), http .delete("/mcp/{name}/auth", "mcp.auth.remove") .mutating() @@ -568,14 +672,25 @@ const scenarios: Scenario[] = [ }), http .post("/mcp/{name}/auth/authenticate", "mcp.auth.authenticate") - .at((ctx) => ({ path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), headers: ctx.headers() })) - .json(400, (body) => { - object(body) - check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") - }, "status"), + .at((ctx) => ({ + path: route("/mcp/{name}/auth/authenticate", { name: "httpapi-missing" }), + headers: ctx.headers(), + })) + .json( + 400, + (body) => { + object(body) + check(typeof body.error === "string", "unsupported MCP OAuth authenticate response should include error") + }, + "status", + ), http .post("/mcp/{name}/auth/callback", "mcp.auth.callback") - .at((ctx) => ({ path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), headers: ctx.headers(), body: { code: 1 } })) + .at((ctx) => ({ + path: route("/mcp/{name}/auth/callback", { name: "httpapi-missing" }), + headers: ctx.headers(), + body: { code: 1 }, + })) .status(400), http .post("/mcp/{name}/connect", "mcp.connect") @@ -597,12 +712,16 @@ const scenarios: Scenario[] = [ .post("/pty", "pty.create") .mutating() .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API PTY") })) - .json(200, (body, ctx) => { - object(body) - check(body.title === "HTTP API PTY", "PTY create should return requested title") - check(body.command === "/bin/sh", "PTY create should use controlled shell command") - check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "HTTP API PTY", "PTY create should return requested title") + check(body.command === "/bin/sh", "PTY create should use controlled shell command") + check(body.cwd === ctx.directory, "PTY create should default cwd to scenario directory") + }, + "status", + ), http .post("/pty", "pty.create.invalid") .at((ctx) => ({ path: "/pty", headers: ctx.headers(), body: { command: 1 } })) @@ -635,7 +754,11 @@ const scenarios: Scenario[] = [ http.get("/experimental/console/orgs", "experimental.console.listOrgs").json(), http .post("/experimental/console/switch", "experimental.console.switchOrg") - .at((ctx) => ({ path: "/experimental/console/switch", headers: ctx.headers(), body: { accountID: "httpapi-account", orgID: "httpapi-org" } })) + .at((ctx) => ({ + path: "/experimental/console/switch", + headers: ctx.headers(), + body: { accountID: "httpapi-account", orgID: "httpapi-org" }, + })) .status(400, undefined, "none"), http.get("/experimental/workspace/adapter", "experimental.workspace.adapter.list").json(200, array), http.get("/experimental/workspace", "experimental.workspace.list").json(200, array), @@ -647,15 +770,25 @@ const scenarios: Scenario[] = [ http .delete("/experimental/workspace/{id}", "experimental.workspace.remove") .mutating() - .at((ctx) => ({ path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/experimental/workspace/{id}", { id: "wrk_httpapi_missing" }), + headers: ctx.headers(), + })) .status(200), http .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") - .at((ctx) => ({ path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), headers: ctx.headers(), body: {} })) + .at((ctx) => ({ + path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), + headers: ctx.headers(), + body: {}, + })) .status(400), http .get("/experimental/tool", "tool.list") - .at((ctx) => ({ path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, headers: ctx.headers() })) + .at((ctx) => ({ + path: `/experimental/tool?${new URLSearchParams({ provider: "opencode", model: "test" })}`, + headers: ctx.headers(), + })) .json(200, array, "status"), http.get("/experimental/tool/ids", "tool.ids").json(200, array), http.get("/experimental/worktree", "worktree.list").json(200, array), @@ -663,13 +796,16 @@ const scenarios: Scenario[] = [ .post("/experimental/worktree", "worktree.create") .mutating() .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: "api-dsl" } })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - object(body) - check(typeof body.directory === "string", "created worktree should include directory") - yield* ctx.worktreeRemove(body.directory) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(typeof body.directory === "string", "created worktree should include directory") + yield* ctx.worktreeRemove(body.directory) + }), + "status", + ), http .post("/experimental/worktree", "worktree.create.invalid") .at((ctx) => ({ path: "/experimental/worktree", headers: ctx.headers(), body: { name: 1 } })) @@ -686,7 +822,11 @@ const scenarios: Scenario[] = [ .post("/experimental/worktree/reset", "worktree.reset") .mutating() .seeded((ctx) => ctx.worktree({ name: "api-reset" })) - .at((ctx) => ({ path: "/experimental/worktree/reset", headers: ctx.headers(), body: { directory: ctx.state.directory } })) + .at((ctx) => ({ + path: "/experimental/worktree/reset", + headers: ctx.headers(), + body: { directory: ctx.state.directory }, + })) .jsonEffect(200, (body, ctx) => Effect.gen(function* () { check(body === true, "worktree reset should return true") @@ -695,17 +835,27 @@ const scenarios: Scenario[] = [ ), http.get("/experimental/session", "experimental.session.list").json(200, array), http.get("/experimental/resource", "experimental.resource.list").json(), - http.post("/sync/history", "sync.history.list").at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })).json(200, array), + http + .post("/sync/history", "sync.history.list") + .at((ctx) => ({ path: "/sync/history", headers: ctx.headers(), body: {} })) + .json(200, array), http .post("/sync/replay", "sync.replay") .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) .status(400), - http.post("/sync/start", "sync.start").mutating().preserveDatabase().json(200, (body) => { - check(body === true, "sync start should return true when no workspace sessions exist") - }), - http.post("/instance/dispose", "instance.dispose").mutating().json(200, (body) => { - check(body === true, "instance dispose should return true") - }), + http + .post("/sync/start", "sync.start") + .mutating() + .preserveDatabase() + .json(200, (body) => { + check(body === true, "sync start should return true when no workspace sessions exist") + }), + http + .post("/instance/dispose", "instance.dispose") + .mutating() + .json(200, (body) => { + check(body === true, "instance dispose should return true") + }), http .post("/log", "app.log") .global() @@ -730,7 +880,10 @@ const scenarios: Scenario[] = [ .global() .seeded(() => Effect.promise(() => - Bun.write(path.join(exerciseDataDirectory, "auth.json"), JSON.stringify({ test: { type: "api", key: "remove-me" } })), + Bun.write( + path.join(exerciseDataDirectory, "auth.json"), + JSON.stringify({ test: { type: "api", key: "remove-me" } }), + ), ), ) .at(() => ({ path: route("/auth/{providerID}", { providerID: "test" }) })) @@ -748,7 +901,10 @@ const scenarios: Scenario[] = [ .at((ctx) => ({ path: "/session?roots=true", headers: ctx.headers() })) .json(200, (body, ctx) => { array(body) - check(body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), "seeded session should be listed") + check( + body.some((item) => isRecord(item) && item.id === ctx.state.id && item.title === "List me"), + "seeded session should be listed", + ) }), http .get("/session/status", "session.status") @@ -758,11 +914,15 @@ const scenarios: Scenario[] = [ .post("/session", "session.create") .mutating() .at((ctx) => ({ path: "/session", headers: ctx.headers(), body: { title: "Created session" } })) - .json(200, (body, ctx) => { - object(body) - check(body.title === "Created session", "created session should use requested title") - check(body.directory === ctx.directory, "created session should use scenario directory") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.title === "Created session", "created session should use requested title") + check(body.directory === ctx.directory, "created session should use scenario directory") + }, + "status", + ), http .get("/session/{sessionID}", "session.get") .seeded((ctx) => ctx.session({ title: "Get me" })) @@ -774,21 +934,36 @@ const scenarios: Scenario[] = [ }), http .get("/session/{sessionID}", "session.get.missing") - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) .status(404), http .patch("/session/{sessionID}", "session.update") .mutating() .seeded((ctx) => ctx.session({ title: "Before rename" })) - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: ctx.state.id }), headers: ctx.headers(), body: { title: "After rename" } })) - .json(200, (body) => { - object(body) - check(body.title === "After rename", "updated session should use new title") - }, "status"), + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { title: "After rename" }, + })) + .json( + 200, + (body) => { + object(body) + check(body.title === "After rename", "updated session should use new title") + }, + "status", + ), http .patch("/session/{sessionID}", "session.update.invalid") .mutating() - .at((ctx) => ({ path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers(), body: { title: 1 } })) + .at((ctx) => ({ + path: route("/session/{sessionID}", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + body: { title: 1 }, + })) .status(400), http .delete("/session/{sessionID}", "session.delete") @@ -810,10 +985,16 @@ const scenarios: Scenario[] = [ return { parent, child } }), ) - .at((ctx) => ({ path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}/children", { sessionID: ctx.state.parent.id }), + headers: ctx.headers(), + })) .json(200, (body, ctx) => { array(body) - check(body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), "children should include seeded child") + check( + body.some((item) => isRecord(item) && item.id === ctx.state.child.id && item.parentID === ctx.state.parent.id), + "children should include seeded child", + ) }), http .get("/session/{sessionID}/todo", "session.todo") @@ -825,7 +1006,10 @@ const scenarios: Scenario[] = [ return { session, todos } }), ) - .at((ctx) => ({ path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}/todo", { sessionID: ctx.state.session.id }), + headers: ctx.headers(), + })) .json(200, (body, ctx) => { check(stable(body) === stable(ctx.state.todos), "todos should match seeded state") }), @@ -861,7 +1045,10 @@ const scenarios: Scenario[] = [ .json(200, (body, ctx) => { object(body) check(isRecord(body.info) && body.info.id === ctx.state.message.info.id, "should return requested message") - check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), "message should include seeded part") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.id === ctx.state.message.part.id), + "message should include seeded part", + ) }), http .patch("/session/{sessionID}/message/{messageID}/part/{partID}", "part.update") @@ -882,10 +1069,14 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { ...ctx.state.message.part, text: "after" }, })) - .json(200, (body) => { - object(body) - check(body.type === "text" && body.text === "after", "updated part should be returned") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(body.type === "text" && body.text === "after", "updated part should be returned") + }, + "status", + ), http .delete("/session/{sessionID}/message/{messageID}/part/{partID}", "part.delete") .mutating() @@ -938,11 +1129,19 @@ const scenarios: Scenario[] = [ .post("/session/{sessionID}/fork", "session.fork") .mutating() .seeded((ctx) => ctx.session({ title: "Fork source" })) - .at((ctx) => ({ path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), headers: ctx.headers(), body: {} })) - .json(200, (body) => { - object(body) - check(typeof body.id === "string", "fork should return a session") - }, "status"), + .at((ctx) => ({ + path: route("/session/{sessionID}/fork", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: {}, + })) + .json( + 200, + (body) => { + object(body) + check(typeof body.id === "string", "fork should return a session") + }, + "status", + ), http .post("/session/{sessionID}/abort", "session.abort") .mutating() @@ -953,7 +1152,10 @@ const scenarios: Scenario[] = [ }), http .post("/session/{sessionID}/abort", "session.abort.missing") - .at((ctx) => ({ path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), headers: ctx.headers() })) + .at((ctx) => ({ + path: route("/session/{sessionID}/abort", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) .json(200, (body) => { check(body === true, "missing session abort should remain a no-op success") }), @@ -1002,14 +1204,20 @@ const scenarios: Scenario[] = [ parts: [{ type: "text", text: "hello llm" }], }, })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") - check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), "assistant message should use fake LLM text") - yield* ctx.llmWait(1) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "prompt should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.text === "fake assistant"), + "assistant message should use fake LLM text", + ) + yield* ctx.llmWait(1) + }), + "status", + ), http .post("/session/{sessionID}/prompt_async", "session.prompt_async") .preserveDatabase() @@ -1053,13 +1261,16 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { command: "init", arguments: "", model: "test/test-model" }, })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") - yield* ctx.llmWait(1) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "command should return assistant message") + yield* ctx.llmWait(1) + }), + "status", + ), http .post("/session/{sessionID}/shell", "session.shell") .preserveDatabase() @@ -1070,11 +1281,18 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { agent: "build", model: { providerID: "test", modelID: "test-model" }, command: "printf shell-ok" }, })) - .json(200, (body) => { - object(body) - check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") - check(Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), "shell should return a tool part") - }, "status"), + .json( + 200, + (body) => { + object(body) + check(isRecord(body.info) && body.info.role === "assistant", "shell should return assistant message") + check( + Array.isArray(body.parts) && body.parts.some((part) => isRecord(part) && part.type === "tool"), + "shell should return a tool part", + ) + }, + "status", + ), http .post("/session/{sessionID}/summarize", "session.summarize") .preserveDatabase() @@ -1122,17 +1340,20 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { providerID: "test", modelID: "test-model", auto: false }, })) - .jsonEffect(200, (body, ctx) => - Effect.gen(function* () { - check(body === true, "summarize should return true") - const messages = yield* ctx.messages(ctx.state.id) - check( - messages.some((message) => message.info.role === "assistant" && message.info.summary === true), - "summarize should create a summary assistant message", - ) - yield* ctx.llmWait(1) - }), - "status"), + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + check(body === true, "summarize should return true") + const messages = yield* ctx.messages(ctx.state.id) + check( + messages.some((message) => message.info.role === "assistant" && message.info.summary === true), + "summarize should create a summary assistant message", + ) + yield* ctx.llmWait(1) + }), + "status", + ), http .post("/session/{sessionID}/revert", "session.revert") .mutating() @@ -1148,25 +1369,42 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), body: { messageID: ctx.state.message.info.id }, })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.session.id, "revert should return the session") - check(isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, "revert should record reverted message") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.session.id, "revert should return the session") + check( + isRecord(body.revert) && body.revert.messageID === ctx.state.message.info.id, + "revert should record reverted message", + ) + }, + "status", + ), http .post("/session/{sessionID}/unrevert", "session.unrevert") .mutating() .seeded((ctx) => ctx.session({ title: "Unrevert session" })) - .at((ctx) => ({ path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "unrevert should return the session") - }, "status"), + .at((ctx) => ({ + path: route("/session/{sessionID}/unrevert", { sessionID: ctx.state.id }), + headers: ctx.headers(), + })) + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unrevert should return the session") + }, + "status", + ), http .post("/session/{sessionID}/permissions/{permissionID}", "permission.respond") .seeded((ctx) => ctx.session({ title: "Deprecated permission session" })) .at((ctx) => ({ - path: route("/session/{sessionID}/permissions/{permissionID}", { sessionID: ctx.state.id, permissionID: "per_httpapi_deprecated" }), + path: route("/session/{sessionID}/permissions/{permissionID}", { + sessionID: ctx.state.id, + permissionID: "per_httpapi_deprecated", + }), headers: ctx.headers(), body: { response: "once" }, })) @@ -1178,19 +1416,27 @@ const scenarios: Scenario[] = [ .mutating() .seeded((ctx) => ctx.session({ title: "Share session" })) .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "share should return the session") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "share should return the session") + }, + "status", + ), http .delete("/session/{sessionID}/share", "session.unshare") .mutating() .seeded((ctx) => ctx.session({ title: "Unshare session" })) .at((ctx) => ({ path: route("/session/{sessionID}/share", { sessionID: ctx.state.id }), headers: ctx.headers() })) - .json(200, (body, ctx) => { - object(body) - check(body.id === ctx.state.id, "unshare should return the session") - }, "status"), + .json( + 200, + (body, ctx) => { + object(body) + check(body.id === ctx.state.id, "unshare should return the session") + }, + "status", + ), http .post("/tui/append-prompt", "tui.appendPrompt") .at((ctx) => ({ path: "/tui/append-prompt", headers: ctx.headers(), body: { text: "hello" } })) @@ -1238,13 +1484,21 @@ const scenarios: Scenario[] = [ .get("/tui/control/next", "tui.control.next") .mutating() .seeded((ctx) => ctx.tuiRequest({ path: "/tui/exercise", body: { text: "queued" } })) - .json(200, (body) => { - object(body) - check(body.path === "/tui/exercise", "control next should return queued path") - object(body.body) - check(body.body.text === "queued", "control next should return queued body") - }, "status"), - http.post("/global/upgrade", "global.upgrade").global().at(() => ({ path: "/global/upgrade", body: { target: 1 } })).status(400), + .json( + 200, + (body) => { + object(body) + check(body.path === "/tui/exercise", "control next should return queued path") + object(body.body) + check(body.body.text === "queued", "control next should return queued body") + }, + "status", + ), + http + .post("/global/upgrade", "global.upgrade") + .global() + .at(() => ({ path: "/global/upgrade", body: { target: 1 } })) + .status(400), ] const main = Effect.gen(function* () { @@ -1259,12 +1513,18 @@ const main = Effect.gen(function* () { printHeader(options, effectRoutes, honoRoutes, selected, missing, extra) - const results = options.mode === "coverage" ? selected.map(coverageResult) : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) + const results = + options.mode === "coverage" + ? selected.map(coverageResult) + : yield* Effect.forEach(selected, runScenario(options), { concurrency: 1 }) printResults(results, missing, extra) - if (results.some((result) => result.status === "fail")) return yield* Effect.fail(new Error("one or more scenarios failed")) - if (options.failOnSkip && results.some((result) => result.status === "skip")) return yield* Effect.fail(new Error("one or more scenarios are skipped")) - if (options.failOnMissing && missing.length > 0) return yield* Effect.fail(new Error("one or more routes have no scenario")) + if (results.some((result) => result.status === "fail")) + return yield* Effect.fail(new Error("one or more scenarios failed")) + if (options.failOnSkip && results.some((result) => result.status === "skip")) + return yield* Effect.fail(new Error("one or more scenarios are skipped")) + if (options.failOnMissing && missing.length > 0) + return yield* Effect.fail(new Error("one or more routes have no scenario")) }) function runScenario(options: Options) { @@ -1322,102 +1582,107 @@ function withContext(scenario: ActiveScenario, use: (ctx: SeededContext Effect.promise(async () => void (await ctx.dir?.[Symbol.asyncDispose]())).pipe(Effect.ignore), ).pipe( - Effect.flatMap((context) => Effect.gen(function* () { - const modules = yield* Effect.promise(() => runtime()) - const path = context.dir?.path - const instance = path - ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), - Effect.catchCause((cause) => - Effect.sleep("100 millis").pipe( - Effect.andThen( - modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( - Effect.provide(modules.AppLayer), + Effect.flatMap((context) => + Effect.gen(function* () { + const modules = yield* Effect.promise(() => runtime()) + const path = context.dir?.path + const instance = path + ? yield* modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + Effect.catchCause((cause) => + Effect.sleep("100 millis").pipe( + Effect.andThen( + modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe( + Effect.provide(modules.AppLayer), + ), ), + Effect.catchCause(() => Effect.failCause(cause)), ), - Effect.catchCause(() => Effect.failCause(cause)), - ), - ), - ) - : undefined - const run = (effect: Effect.Effect) => - effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) - const directory = () => { - if (!context.dir?.path) throw new Error("scenario needs a project directory") - return context.dir.path - } - const llm = () => { - if (!context.llm) throw new Error("scenario needs fake LLM") - return context.llm - } - const base: ScenarioContext = { - directory: context.dir?.path, - headers: (extra) => ({ ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), ...extra }), - file: (name, content) => - Effect.promise(() => { - return Bun.write(`${directory()}/${name}`, content) - }).pipe(Effect.asVoid), - session: (input) => - run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), - sessionGet: (sessionID) => - run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( - Effect.catchCause(() => Effect.succeed(undefined)), - ), - project: () => - Effect.sync(() => { - if (!instance) throw new Error("scenario needs a project directory") - return instance.project - }), - message: (sessionID, input) => - Effect.gen(function* () { - const info: MessageV2.User = { - id: MessageID.ascending(), - sessionID, - role: "user", - time: { created: Date.now() }, - agent: "build", - model: { - providerID: ProviderID.opencode, - modelID: ModelID.make("test"), - }, - } - const part: MessageV2.TextPart = { - id: PartID.ascending(), - sessionID, - messageID: info.id, - type: "text", - text: input?.text ?? "hello", - } - yield* run( - modules.Session.Service.use((svc) => - Effect.gen(function* () { - yield* svc.updateMessage(info) - yield* svc.updatePart(part) - }), ), ) - return { info, part } + : undefined + const run = (effect: Effect.Effect) => + effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer)) + const directory = () => { + if (!context.dir?.path) throw new Error("scenario needs a project directory") + return context.dir.path + } + const llm = () => { + if (!context.llm) throw new Error("scenario needs fake LLM") + return context.llm + } + const base: ScenarioContext = { + directory: context.dir?.path, + headers: (extra) => ({ + ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}), + ...extra, }), - messages: (sessionID) => - run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), - todos: (sessionID, todos) => - run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), - worktree: (input) => - run(modules.Worktree.Service.use((svc) => svc.create(input))), - worktreeRemove: (directory) => - run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), - llmText: (value) => Effect.suspend(() => llm().text(value)), - llmWait: (count) => Effect.suspend(() => llm().wait(count)), - tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), - } - const state = yield* scenario.seed(base) - return yield* use({ ...base, state }) - }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void))), + file: (name, content) => + Effect.promise(() => { + return Bun.write(`${directory()}/${name}`, content) + }).pipe(Effect.asVoid), + session: (input) => + run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))), + sessionGet: (sessionID) => + run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ), + project: () => + Effect.sync(() => { + if (!instance) throw new Error("scenario needs a project directory") + return instance.project + }), + message: (sessionID, input) => + Effect.gen(function* () { + const info: MessageV2.User = { + id: MessageID.ascending(), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { + providerID: ProviderID.opencode, + modelID: ModelID.make("test"), + }, + } + const part: MessageV2.TextPart = { + id: PartID.ascending(), + sessionID, + messageID: info.id, + type: "text", + text: input?.text ?? "hello", + } + yield* run( + modules.Session.Service.use((svc) => + Effect.gen(function* () { + yield* svc.updateMessage(info) + yield* svc.updatePart(part) + }), + ), + ) + return { info, part } + }), + messages: (sessionID) => run(modules.Session.Service.use((svc) => svc.messages({ sessionID }))), + todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))), + worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input))), + worktreeRemove: (directory) => + run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)), + llmText: (value) => Effect.suspend(() => llm().text(value)), + llmWait: (count) => Effect.suspend(() => llm().wait(count)), + tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)), + } + const state = yield* scenario.seed(base) + return yield* use({ ...base, state }) + }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)), + ), Effect.ensuring(scenario.reset ? resetState : Effect.void), ) } -function projectOptions(project: ProjectOptions, llmUrl: string | undefined): { git?: boolean; config?: Partial } { +function projectOptions( + project: ProjectOptions, + llmUrl: string | undefined, +): { git?: boolean; config?: Partial } { if (!project.llm || !llmUrl) return { git: project.git, config: project.config } const fake = fakeLlmConfig(llmUrl) return { @@ -1475,7 +1740,9 @@ function controlledPtyInput(title: string | undefined) { } function call(backend: Backend, scenario: ActiveScenario, ctx: SeededContext) { - return Effect.promise(async () => capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture)) + return Effect.promise(async () => + capture(await app(await runtime(), backend).request(toRequest(scenario, ctx)), scenario.capture), + ) } const appCache: Partial> = {} @@ -1494,13 +1761,20 @@ function app(modules: Runtime, backend: Backend) { const handler = HttpRouter.toWebHandler( modules.ExperimentalHttpApiServer.routes.pipe( - Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }))), + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined }), + ), + ), ), { disableLogger: true }, ).handler return (appCache.effect = { request(input: string | URL | Request, init?: RequestInit) { - return handler(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), modules.ExperimentalHttpApiServer.context) + return handler( + input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init), + modules.ExperimentalHttpApiServer.context, + ) }, }) } @@ -1545,16 +1819,23 @@ async function captureStream(response: Response) { const cleanupExercisePaths = Effect.promise(async () => { const fs = await import("fs/promises") if (!preserveExerciseDatabase) { - await Promise.all([exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => fs.rm(file, { force: true }).catch(() => undefined))) + await Promise.all( + [exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) => + fs.rm(file, { force: true }).catch(() => undefined), + ), + ) } - if (!preserveExerciseGlobalRoot) await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) + if (!preserveExerciseGlobalRoot) + await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined) }) function compare(scenario: ActiveScenario, effect: CallResult, legacy: CallResult) { return Effect.sync(() => { - if (effect.status !== legacy.status) throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) + if (effect.status !== legacy.status) + throw new Error(`legacy returned ${legacy.status}, effect returned ${effect.status}`) if (scenario.compare === "status") return - if (stable(effect.body) !== stable(legacy.body)) throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) + if (stable(effect.body) !== stable(legacy.body)) + throw new Error(`JSON parity mismatch\nlegacy: ${stable(legacy.body)}\neffect: ${stable(effect.body)}`) }) } @@ -1570,7 +1851,9 @@ const resetState = Effect.promise(async () => { function routeKeys(spec: OpenApiSpec) { return Object.entries(spec.paths ?? {}) - .flatMap(([path, item]) => OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`)) + .flatMap(([path, item]) => + OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`), + ) .sort() } @@ -1602,10 +1885,21 @@ function option(args: string[], name: string) { function matches(options: Options, scenario: Scenario) { if (!options.include) return true - return scenario.name.includes(options.include) || scenario.path.includes(options.include) || scenario.method.includes(options.include.toUpperCase()) + return ( + scenario.name.includes(options.include) || + scenario.path.includes(options.include) || + scenario.method.includes(options.include.toUpperCase()) + ) } -function printHeader(options: Options, effectRoutes: string[], honoRoutes: string[], selected: Scenario[], missing: string[], extra: Scenario[]) { +function printHeader( + options: Options, + effectRoutes: string[], + honoRoutes: string[], + selected: Scenario[], + missing: string[], + extra: Scenario[], +) { console.log(`${color.cyan}HttpApi exerciser${color.reset}`) console.log(`${color.dim}db=${exerciseDatabasePath}${color.reset}`) console.log(`${color.dim}global=${exerciseGlobalRoot}${color.reset}`) @@ -1618,14 +1912,20 @@ function printHeader(options: Options, effectRoutes: string[], honoRoutes: strin function printResults(results: Result[], missing: string[], extra: Scenario[]) { for (const result of results) { if (result.status === "pass") { - console.log(`${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + console.log( + `${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) continue } if (result.status === "skip") { - console.log(`${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`) + console.log( + `${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`, + ) continue } - console.log(`${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`) + console.log( + `${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`, + ) console.log(`${color.red}${indent(result.message)}${color.reset}`) } if (missing.length > 0) { @@ -1634,7 +1934,8 @@ function printResults(results: Result[], missing: string[], extra: Scenario[]) { } if (extra.length > 0) { console.log("\nExtra scenarios") - for (const scenario of extra) console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) + for (const scenario of extra) + console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`) } console.log( `\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`, @@ -1661,7 +1962,11 @@ function stable(value: unknown): string { function sort(value: unknown): unknown { if (Array.isArray(value)) return value.map(sort) if (!value || typeof value !== "object") return value - return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, sort(item)])) + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, sort(item)]), + ) } function array(value: unknown): asserts value is unknown[] { diff --git a/packages/opencode/test/lib/effect.ts b/packages/opencode/test/lib/effect.ts index 2fbf5ca11b..e454fa7e42 100644 --- a/packages/opencode/test/lib/effect.ts +++ b/packages/opencode/test/lib/effect.ts @@ -61,7 +61,11 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) - return test(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + return test( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) } instance.only = ( @@ -71,7 +75,11 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) - return test.only(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + return test.only( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) } instance.skip = ( @@ -81,7 +89,11 @@ const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) opts?: number | TestOptions, ) => { const args = instanceArgs(options, opts) - return test.skip(name, () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), args.testOptions) + return test.skip( + name, + () => run(body(value).pipe(withTmpdirInstance(args.instanceOptions)), liveLayer), + args.testOptions, + ) } return { effect, live, instance } diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 461fb88f26..9e577ec3cd 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -54,11 +54,36 @@ const waitForPending = (count: number) => return yield* Effect.fail(new Error(`timed out waiting for ${count} pending question request(s)`)) }) -it.instance("ask - remains pending until answered", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ +it.instance( + "ask - remains pending until answered", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) + + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), + { git: true }, +) + +it.instance( + "ask - adds to pending list", + () => + Effect.gen(function* () { + const questions = [ { question: "What would you like to do?", header: "Action", @@ -67,81 +92,29 @@ it.instance("ask - remains pending until answered", () => { label: "Option 2", description: "Second option" }, ], }, - ], - }).pipe(Effect.forkScoped) - - expect(yield* waitForPending(1)).toHaveLength(1) - yield* rejectAll - expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - }), - { git: true }, -) - -it.instance("ask - adds to pending list", () => - Effect.gen(function* () { - const questions = [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ] - - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions, - }).pipe(Effect.forkScoped) - - const pending = yield* waitForPending(1) - expect(pending.length).toBe(1) - expect(pending[0].questions).toEqual(questions) - yield* rejectAll - expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - }), + ] + + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) + expect(pending[0].questions).toEqual(questions) + yield* rejectAll + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + }), { git: true }, ) // reply tests -it.instance("reply - resolves the pending ask with answers", () => - Effect.gen(function* () { - const questions = [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ] - - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions, - }).pipe(Effect.forkScoped) - - const pending = yield* waitForPending(1) - const requestID = pending[0].id - - yield* replyEffect({ - requestID, - answers: [["Option 1"]], - }) - - expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]]) - }), - { git: true }, -) - -it.instance("reply - removes from pending list", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ +it.instance( + "reply - resolves the pending ask with answers", + () => + Effect.gen(function* () { + const questions = [ { question: "What would you like to do?", header: "Action", @@ -150,170 +123,219 @@ it.instance("reply - removes from pending list", () => { label: "Option 2", description: "Second option" }, ], }, - ], - }).pipe(Effect.forkScoped) + ] - const pending = yield* waitForPending(1) - expect(pending.length).toBe(1) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) - yield* replyEffect({ - requestID: pending[0].id, - answers: [["Option 1"]], - }) - yield* Fiber.join(fiber) + const pending = yield* waitForPending(1) + const requestID = pending[0].id - const after = yield* listEffect - expect(after.length).toBe(0) - }), + yield* replyEffect({ + requestID, + answers: [["Option 1"]], + }) + + expect(yield* Fiber.join(fiber)).toEqual([["Option 1"]]) + }), { git: true }, ) -it.instance("reply - does nothing for unknown requestID", () => - replyEffect({ - requestID: QuestionID.make("que_unknown"), - answers: [["Option 1"]], - }), +it.instance( + "reply - removes from pending list", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) + + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Option 1"]], + }) + yield* Fiber.join(fiber) + + const after = yield* listEffect + expect(after.length).toBe(0) + }), + { git: true }, +) + +it.instance( + "reply - does nothing for unknown requestID", + () => + replyEffect({ + requestID: QuestionID.make("que_unknown"), + answers: [["Option 1"]], + }), { git: true }, ) // reject tests -it.instance("reject - throws RejectedError", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }).pipe(Effect.forkScoped) +it.instance( + "reject - throws RejectedError", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) - const pending = yield* waitForPending(1) - yield* rejectEffect(pending[0].id) + const pending = yield* waitForPending(1) + yield* rejectEffect(pending[0].id) - const exit = yield* Fiber.await(fiber) - expect(exit._tag).toBe("Failure") - if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError") - }), + const exit = yield* Fiber.await(fiber) + expect(exit._tag).toBe("Failure") + if (exit._tag === "Failure") expect(exit.cause.toString()).toContain("QuestionRejectedError") + }), { git: true }, ) -it.instance("reject - removes from pending list", () => - Effect.gen(function* () { - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions: [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ], - }).pipe(Effect.forkScoped) +it.instance( + "reject - removes from pending list", + () => + Effect.gen(function* () { + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions: [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Option 1", description: "First option" }, + { label: "Option 2", description: "Second option" }, + ], + }, + ], + }).pipe(Effect.forkScoped) - const pending = yield* waitForPending(1) - expect(pending.length).toBe(1) + const pending = yield* waitForPending(1) + expect(pending.length).toBe(1) - yield* rejectEffect(pending[0].id) - expect((yield* Fiber.await(fiber))._tag).toBe("Failure") + yield* rejectEffect(pending[0].id) + expect((yield* Fiber.await(fiber))._tag).toBe("Failure") - const after = yield* listEffect - expect(after.length).toBe(0) - }), + const after = yield* listEffect + expect(after.length).toBe(0) + }), { git: true }, ) -it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { git: true }) +it.instance("reject - does nothing for unknown requestID", () => rejectEffect(QuestionID.make("que_unknown")), { + git: true, +}) // multiple questions tests -it.instance("ask - handles multiple questions", () => - Effect.gen(function* () { - const questions = [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Build", description: "Build the project" }, - { label: "Test", description: "Run tests" }, - ], - }, - { - question: "Which environment?", - header: "Env", - options: [ - { label: "Dev", description: "Development" }, - { label: "Prod", description: "Production" }, - ], - }, - ] +it.instance( + "ask - handles multiple questions", + () => + Effect.gen(function* () { + const questions = [ + { + question: "What would you like to do?", + header: "Action", + options: [ + { label: "Build", description: "Build the project" }, + { label: "Test", description: "Run tests" }, + ], + }, + { + question: "Which environment?", + header: "Env", + options: [ + { label: "Dev", description: "Development" }, + { label: "Prod", description: "Production" }, + ], + }, + ] - const fiber = yield* askEffect({ - sessionID: SessionID.make("ses_test"), - questions, - }).pipe(Effect.forkScoped) + const fiber = yield* askEffect({ + sessionID: SessionID.make("ses_test"), + questions, + }).pipe(Effect.forkScoped) - const pending = yield* waitForPending(1) + const pending = yield* waitForPending(1) - yield* replyEffect({ - requestID: pending[0].id, - answers: [["Build"], ["Dev"]], - }) + yield* replyEffect({ + requestID: pending[0].id, + answers: [["Build"], ["Dev"]], + }) - expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]]) - }), + expect(yield* Fiber.join(fiber)).toEqual([["Build"], ["Dev"]]) + }), { git: true }, ) // list tests -it.instance("list - returns all pending requests", () => - Effect.gen(function* () { - const fiber1 = yield* askEffect({ - sessionID: SessionID.make("ses_test1"), - questions: [ - { - question: "Question 1?", - header: "Q1", - options: [{ label: "A", description: "A" }], - }, - ], - }).pipe(Effect.forkScoped) - - const fiber2 = yield* askEffect({ - sessionID: SessionID.make("ses_test2"), - questions: [ - { - question: "Question 2?", - header: "Q2", - options: [{ label: "B", description: "B" }], - }, - ], - }).pipe(Effect.forkScoped) - - const pending = yield* waitForPending(2) - expect(pending.length).toBe(2) - yield* rejectAll - expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") - expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") - }), +it.instance( + "list - returns all pending requests", + () => + Effect.gen(function* () { + const fiber1 = yield* askEffect({ + sessionID: SessionID.make("ses_test1"), + questions: [ + { + question: "Question 1?", + header: "Q1", + options: [{ label: "A", description: "A" }], + }, + ], + }).pipe(Effect.forkScoped) + + const fiber2 = yield* askEffect({ + sessionID: SessionID.make("ses_test2"), + questions: [ + { + question: "Question 2?", + header: "Q2", + options: [{ label: "B", description: "B" }], + }, + ], + }).pipe(Effect.forkScoped) + + const pending = yield* waitForPending(2) + expect(pending.length).toBe(2) + yield* rejectAll + expect((yield* Fiber.await(fiber1))._tag).toBe("Failure") + expect((yield* Fiber.await(fiber2))._tag).toBe("Failure") + }), { git: true }, ) -it.instance("list - returns empty when no pending", () => - Effect.gen(function* () { - const pending = yield* listEffect - expect(pending.length).toBe(0) - }), +it.instance( + "list - returns empty when no pending", + () => + Effect.gen(function* () { + const pending = yield* listEffect + expect(pending.length).toBe(0) + }), { git: true }, ) From 7d91d3b1ed3d5385d9f2e5a6976d6ac32f98cf18 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:39:20 -0400 Subject: [PATCH 063/178] Normalize instance lifecycle wiring (#25501) --- packages/opencode/src/cli/bootstrap.ts | 3 +- packages/opencode/src/cli/cmd/agent.ts | 5 +- packages/opencode/src/cli/cmd/github.ts | 3 +- packages/opencode/src/cli/cmd/mcp.ts | 13 +- packages/opencode/src/cli/cmd/providers.ts | 4 +- .../src/cli/cmd/tui/plugin/runtime.ts | 6 +- packages/opencode/src/cli/cmd/tui/worker.ts | 4 +- packages/opencode/src/effect/app-runtime.ts | 7 +- .../opencode/src/project/instance-layer.ts | 11 + .../opencode/src/project/instance-runtime.ts | 31 +- .../opencode/src/project/instance-store.ts | 27 +- packages/opencode/src/project/instance.ts | 7 - .../opencode/src/project/with-instance.ts | 10 + .../src/server/routes/instance/config.ts | 37 +- .../server/routes/instance/httpapi/server.ts | 6 +- .../src/server/routes/instance/middleware.ts | 4 +- packages/opencode/src/server/workspace.ts | 4 +- packages/opencode/src/worktree/index.ts | 27 +- .../test/acp/event-subscription.test.ts | 21 +- packages/opencode/test/agent/agent.test.ts | 77 ++-- .../agent/plugin-agent-regression.test.ts | 3 +- .../opencode/test/bus/bus-integration.test.ts | 3 +- packages/opencode/test/bus/bus.test.ts | 3 +- packages/opencode/test/config/config.test.ts | 105 +++--- .../test/control-plane/workspace.test.ts | 3 +- packages/opencode/test/file/fsmonitor.test.ts | 5 +- packages/opencode/test/file/index.test.ts | 109 +++--- .../opencode/test/file/path-traversal.test.ts | 23 +- packages/opencode/test/file/watcher.test.ts | 5 +- packages/opencode/test/fixture/fixture.ts | 3 +- packages/opencode/test/lsp/client.test.ts | 25 +- packages/opencode/test/mcp/headers.test.ts | 7 +- packages/opencode/test/mcp/lifecycle.test.ts | 3 +- .../test/mcp/oauth-auto-connect.test.ts | 9 +- .../opencode/test/mcp/oauth-browser.test.ts | 7 +- .../opencode/test/permission-task.test.ts | 13 +- .../opencode/test/permission/next.test.ts | 3 +- .../instance-bootstrap-regression.test.ts | 3 +- .../opencode/test/project/instance.test.ts | 124 +++---- packages/opencode/test/project/vcs.test.ts | 5 +- .../opencode/test/project/worktree.test.ts | 5 +- .../test/provider/amazon-bedrock.test.ts | 61 ++- .../opencode/test/provider/gitlab-duo.test.ts | 27 +- .../opencode/test/provider/provider.test.ts | 347 +++++++----------- .../test/pty/pty-output-isolation.test.ts | 7 +- .../opencode/test/pty/pty-session.test.ts | 5 +- packages/opencode/test/pty/pty-shell.test.ts | 7 +- .../opencode/test/question/question.test.ts | 3 +- .../test/server/global-session-list.test.ts | 13 +- .../test/server/httpapi-experimental.test.ts | 5 +- .../server/httpapi-instance-context.test.ts | 4 +- .../opencode/test/server/httpapi-mcp.test.ts | 3 +- .../test/server/httpapi-provider.test.ts | 3 +- .../opencode/test/server/httpapi-sdk.test.ts | 3 +- .../test/server/httpapi-session.test.ts | 5 +- .../opencode/test/server/httpapi-sync.test.ts | 3 +- .../test/server/session-actions.test.ts | 3 +- .../opencode/test/server/session-list.test.ts | 41 ++- .../test/server/session-messages.test.ts | 9 +- .../test/server/session-select.test.ts | 7 +- .../opencode/test/session/compaction.test.ts | 39 +- packages/opencode/test/session/llm.test.ts | 17 +- .../test/session/messages-pagination.test.ts | 85 ++--- .../opencode/test/session/session.test.ts | 9 +- .../structured-output-integration.test.ts | 3 +- .../opencode/test/snapshot/snapshot.test.ts | 115 +++--- .../opencode/test/tool/apply_patch.test.ts | 49 +-- packages/opencode/test/tool/edit.test.ts | 37 +- .../test/tool/external-directory.test.ts | 15 +- packages/opencode/test/tool/shell.test.ts | 83 ++--- packages/opencode/test/tool/webfetch.test.ts | 7 +- 71 files changed, 852 insertions(+), 936 deletions(-) create mode 100644 packages/opencode/src/project/instance-layer.ts create mode 100644 packages/opencode/src/project/with-instance.ts diff --git a/packages/opencode/src/cli/bootstrap.ts b/packages/opencode/src/cli/bootstrap.ts index 81a085d689..fa39ecb177 100644 --- a/packages/opencode/src/cli/bootstrap.ts +++ b/packages/opencode/src/cli/bootstrap.ts @@ -1,8 +1,9 @@ import { Instance } from "../project/instance" import { InstanceRuntime } from "../project/instance-runtime" +import { WithInstance } from "../project/with-instance" export async function bootstrap(directory: string, cb: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { try { diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index a1a440eaa1..11a6c7f430 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -10,6 +10,7 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import matter from "gray-matter" import { Instance } from "../../project/instance" +import { WithInstance } from "../../project/with-instance" import { EOL } from "os" import type { Argv } from "yargs" @@ -61,7 +62,7 @@ const AgentCreateCommand = cmd({ describe: "model to use in the format of provider/model", }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { const cliPath = args.path @@ -236,7 +237,7 @@ const AgentListCommand = cmd({ command: "list", describe: "list all available agents", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index a75dc31634..e707526dfe 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -20,6 +20,7 @@ import { UI } from "../ui" import { cmd } from "./cmd" import { ModelsDev } from "@/provider/models" import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { bootstrap } from "../bootstrap" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" @@ -203,7 +204,7 @@ export const GithubInstallCommand = cmd({ command: "install", describe: "install the GitHub agent", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { { diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index d244549fff..e4d7bd9224 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -10,6 +10,7 @@ import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" import { ConfigMCP } from "../../config/mcp" import { Instance } from "../../project/instance" +import { WithInstance } from "../../project/with-instance" import { Installation } from "../../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" @@ -114,7 +115,7 @@ export const McpListCommand = cmd({ aliases: ["ls"], describe: "list MCP servers and their status", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -186,7 +187,7 @@ export const McpAuthCommand = cmd({ }) .command(McpAuthListCommand), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -318,7 +319,7 @@ export const McpAuthListCommand = cmd({ aliases: ["ls"], describe: "list OAuth-capable MCP servers and their auth status", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -357,7 +358,7 @@ export const McpLogoutCommand = cmd({ type: "string", }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -448,7 +449,7 @@ export const McpAddCommand = cmd({ command: "add", describe: "add an MCP server", async handler() { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() @@ -618,7 +619,7 @@ export const McpDebugCommand = cmd({ demandOption: true, }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index c383e79ce8..ca64526182 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -13,7 +13,7 @@ import os from "os" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" -import { Instance } from "../../project/instance" +import { WithInstance } from "../../project/with-instance" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" import { text } from "node:stream/consumers" @@ -303,7 +303,7 @@ export const ProvidersLoginCommand = cmd({ type: "string", }), async handler(args) { - await Instance.provide({ + await WithInstance.provide({ directory: process.cwd(), async fn() { UI.empty() diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 2ef5333245..73193d142e 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -16,7 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui" import * as Log from "@opencode-ai/core/util/log" import { errorData, errorMessage } from "@/util/error" import { isRecord } from "@/util/record" -import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { readPackageThemes, readPluginId, @@ -790,7 +790,7 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) { state.pending.delete(spec) return true } - const ready = await Instance.provide({ + const ready = await WithInstance.provide({ directory: state.directory, fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()), }).catch((error) => { @@ -986,7 +986,7 @@ async function load(input: { api: Api; config: TuiConfig.Info }) { } runtime = next try { - await Instance.provide({ + await WithInstance.provide({ directory: cwd, fn: async () => { const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index e4fbeb2fbc..775f321bb5 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -1,8 +1,8 @@ import { Installation } from "@/installation" import { Server } from "@/server/server" import * as Log from "@opencode-ai/core/util/log" -import { Instance } from "@/project/instance" import { InstanceRuntime } from "@/project/instance-runtime" +import { WithInstance } from "@/project/with-instance" import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" @@ -77,7 +77,7 @@ export const rpc = { return { url: server.url.toString() } }, async checkUpgrade(input: { directory: string }) { - await Instance.provide({ + await WithInstance.provide({ directory: input.directory, fn: async () => { await upgrade().catch(() => {}) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 901738646c..e8c8025ea3 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -40,7 +40,7 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" -import { InstanceRuntime } from "@/project/instance-runtime" +import { InstanceLayer } from "@/project/instance-layer" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" @@ -93,17 +93,16 @@ export const AppLayer = Layer.mergeAll( Truncate.defaultLayer, ToolRegistry.defaultLayer, Format.defaultLayer, - InstanceRuntime.layer, Project.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, - Worktree.defaultLayer, + Worktree.appLayer, Pty.defaultLayer, Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, SyncEvent.defaultLayer, -).pipe(Layer.provideMerge(Observability.layer)) +).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick diff --git a/packages/opencode/src/project/instance-layer.ts b/packages/opencode/src/project/instance-layer.ts new file mode 100644 index 0000000000..a7e2bfcb7b --- /dev/null +++ b/packages/opencode/src/project/instance-layer.ts @@ -0,0 +1,11 @@ +import { Effect, Layer } from "effect" +import { InstanceStore } from "./instance-store" + +export const layer = Layer.unwrap( + Effect.promise(async () => { + const { InstanceBootstrap } = await import("./bootstrap") + return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) + }), +) + +export * as InstanceLayer from "./instance-layer" diff --git a/packages/opencode/src/project/instance-runtime.ts b/packages/opencode/src/project/instance-runtime.ts index a30bf56107..c8803847a0 100644 --- a/packages/opencode/src/project/instance-runtime.ts +++ b/packages/opencode/src/project/instance-runtime.ts @@ -1,27 +1,16 @@ -import { makeRuntime } from "@/effect/run-service" +import { AppRuntime } from "@/effect/app-runtime" import { type InstanceContext } from "./instance-context" import { InstanceStore, type LoadInput } from "./instance-store" -import { Effect, Layer } from "effect" -// Production InstanceStore wiring plus a bridge for Promise/ALS callers that -// cannot yet yield InstanceStore.Service. This keeps InstanceStore itself -// low-level while still giving legacy Hono and CLI paths the production -// bootstrap implementation. Delete the Promise helpers once those callers are -// migrated to Effect boundaries that provide InstanceStore directly. -// Keep the bootstrap implementation import lazy: Instance is imported broadly, -// and importing the app bootstrap graph at module load can trigger ESM cycles. -export const layer = Layer.unwrap( - Effect.promise(async () => { - const { InstanceBootstrap } = await import("./bootstrap") - return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) - }), -) +// Bridge for Promise/ALS callers that cannot yet yield InstanceStore.Service. +// Delete this module once those callers are migrated to Effect boundaries that +// provide InstanceStore directly. -const runtime = makeRuntime(InstanceStore.Service, layer) - -export const load = (input: LoadInput) => runtime.runPromise((store) => store.load(input)) -export const disposeInstance = (ctx: InstanceContext) => runtime.runPromise((store) => store.dispose(ctx)) -export const disposeAllInstances = () => runtime.runPromise((store) => store.disposeAll()) -export const reloadInstance = (input: LoadInput) => runtime.runPromise((store) => store.reload(input)) +export const load = (input: LoadInput) => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load(input))) +export const disposeInstance = (ctx: InstanceContext) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.dispose(ctx))) +export const disposeAllInstances = () => AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeAll())) +export const reloadInstance = (input: LoadInput) => + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.reload(input))) export * as InstanceRuntime from "./instance-runtime" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 41adcbc7cf..4fa1c3dfff 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -8,26 +8,18 @@ import { type InstanceContext } from "./instance-context" import { InstanceBootstrap } from "./bootstrap-service" import * as Project from "./project" -export interface LoadInput { +export interface LoadInput { directory: string - /** - * Additional setup to run after the default InstanceBootstrap. - * Mainly used by tests for env-var setup or file writes that need the instance ALS context. - */ - init?: Effect.Effect worktree?: string project?: Project.Info } export interface Interface { - readonly load: (input: LoadInput) => Effect.Effect - readonly reload: (input: LoadInput) => Effect.Effect + readonly load: (input: LoadInput) => Effect.Effect + readonly reload: (input: LoadInput) => Effect.Effect readonly dispose: (ctx: InstanceContext) => Effect.Effect readonly disposeAll: () => Effect.Effect - readonly provide: ( - input: LoadInput, - effect: Effect.Effect, - ) => Effect.Effect + readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect } export class Service extends Context.Service()("@opencode/InstanceStore") {} @@ -44,7 +36,7 @@ export const layer: Layer.Layer() - const boot = (input: LoadInput & { directory: string }) => + const boot = (input: LoadInput & { directory: string }) => Effect.gen(function* () { const ctx: InstanceContext = input.project && input.worktree @@ -61,7 +53,6 @@ export const layer: Layer.Layer(directory: string, input: LoadInput, entry: Entry) => + const completeLoad = (directory: string, input: LoadInput, entry: Entry) => Effect.gen(function* () { const exit = yield* Effect.exit(boot({ ...input, directory })) if (Exit.isFailure(exit)) yield* removeEntry(directory, entry) @@ -108,7 +99,7 @@ export const layer: Layer.Layer(input: LoadInput): Effect.Effect => { + const load = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -126,7 +117,7 @@ export const layer: Layer.Layer(input: LoadInput): Effect.Effect => { + const reload = (input: LoadInput): Effect.Effect => { const directory = AppFileSystem.resolve(input.directory) return Effect.uninterruptibleMask((restore) => Effect.gen(function* () { @@ -180,7 +171,7 @@ export const layer: Layer.Layer(input: LoadInput, effect: Effect.Effect): Effect.Effect => + const provide = (input: LoadInput, effect: Effect.Effect): Effect.Effect => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 81977affc3..a54291cf0c 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -1,15 +1,8 @@ -import { Effect } from "effect" import { context, type InstanceContext } from "./instance-context" -import { InstanceRuntime } from "./instance-runtime" export type { InstanceContext } from "./instance-context" -export type { LoadInput } from "./instance-store" export const Instance = { - async provide(input: { directory: string; init?: Effect.Effect; fn: () => R }): Promise { - const ctx = await InstanceRuntime.load({ directory: input.directory, init: input.init }) - return context.provide(ctx, async () => input.fn()) - }, get current() { return context.use() }, diff --git a/packages/opencode/src/project/with-instance.ts b/packages/opencode/src/project/with-instance.ts new file mode 100644 index 0000000000..b5b0e7c079 --- /dev/null +++ b/packages/opencode/src/project/with-instance.ts @@ -0,0 +1,10 @@ +import { AppRuntime } from "@/effect/app-runtime" +import { context } from "./instance-context" +import { InstanceStore } from "./instance-store" + +export async function provide(input: { directory: string; fn: () => R }): Promise { + const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: input.directory }))) + return context.provide(ctx, () => input.fn()) +} + +export * as WithInstance from "./with-instance" diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index 96a7e756de..949734f81a 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -6,7 +6,11 @@ import { InstanceStore } from "@/project/instance-store" import { Provider } from "@/provider/provider" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import { jsonRequest } from "./trace" +import { jsonRequest, runRequest } from "./trace" +import { Effect } from "effect" +import * as Log from "@opencode-ai/core/util/log" + +const log = Log.create({ service: "server.config" }) export const ConfigRoutes = lazy(() => new Hono() @@ -52,15 +56,28 @@ export const ConfigRoutes = lazy(() => }, }), validator("json", Config.Info.zod), - async (c) => - jsonRequest("ConfigRoutes.update", c, function* () { - const config = c.req.valid("json") - const cfg = yield* Config.Service - const store = yield* InstanceStore.Service - yield* cfg.update(config) - yield* store.dispose(yield* InstanceState.context) - return config - }), + async (c) => { + const result = await runRequest( + "ConfigRoutes.update", + c, + Effect.gen(function* () { + const config = c.req.valid("json") + const cfg = yield* Config.Service + yield* cfg.update(config) + return { config, ctx: yield* InstanceState.context } + }), + ) + const response = c.json(result.config) + void runRequest( + "ConfigRoutes.update.dispose", + c, + InstanceStore.Service.use((store) => store.dispose(result.ctx)).pipe( + Effect.uninterruptible, + Effect.catchCause((cause) => Effect.sync(() => log.warn("instance disposal failed", { cause }))), + ), + ) + return response + }, ) .get( "/providers", diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index ce1b213729..0b4bc252c3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -18,7 +18,7 @@ import { LSP } from "@/lsp/lsp" import { MCP } from "@/mcp" import { Permission } from "@/permission" import { Installation } from "@/installation" -import { InstanceRuntime } from "@/project/instance-runtime" +import { InstanceLayer } from "@/project/instance-layer" import { Plugin } from "@/plugin" import { Project } from "@/project/project" import { ProviderAuth } from "@/provider/auth" @@ -152,7 +152,6 @@ export function createRoutes(corsOptions?: CorsOptions) { Format.defaultLayer, LSP.defaultLayer, Installation.defaultLayer, - InstanceRuntime.layer, MCP.defaultLayer, ModelsDev.defaultLayer, Permission.defaultLayer, @@ -179,12 +178,13 @@ export function createRoutes(corsOptions?: CorsOptions) { ToolRegistry.defaultLayer, Vcs.defaultLayer, Workspace.defaultLayer, - Worktree.defaultLayer, + Worktree.appLayer, Bus.layer, AppFileSystem.defaultLayer, FetchHttpClient.layer, HttpServer.layerServices, ]), + Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer), ) } diff --git a/packages/opencode/src/server/routes/instance/middleware.ts b/packages/opencode/src/server/routes/instance/middleware.ts index 494459500d..23707faf79 100644 --- a/packages/opencode/src/server/routes/instance/middleware.ts +++ b/packages/opencode/src/server/routes/instance/middleware.ts @@ -1,5 +1,5 @@ import type { MiddlewareHandler } from "hono" -import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceID } from "@/control-plane/schema" @@ -20,7 +20,7 @@ export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler return WorkspaceContext.provide({ workspaceID, async fn() { - return Instance.provide({ + return WithInstance.provide({ directory, async fn() { return next() diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index dbf693e8fc..f5f667222f 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -6,7 +6,7 @@ import { WorkspaceContext } from "@/control-plane/workspace-context" import { Workspace } from "@/control-plane/workspace" import { Flag } from "@opencode-ai/core/flag/flag" import { AppRuntime } from "@/effect/app-runtime" -import { Instance } from "@/project/instance" +import { WithInstance } from "@/project/with-instance" import { Session } from "@/session/session" import { SessionID } from "@/session/schema" import { Effect } from "effect" @@ -97,7 +97,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware return WorkspaceContext.provide({ workspaceID: WorkspaceID.make(workspaceID), fn: () => - Instance.provide({ + WithInstance.provide({ directory: target.directory, async fn() { return next() diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 2e9b6736f5..43453b561a 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,7 +1,8 @@ import z from "zod" import { NamedError } from "@opencode-ai/core/util/error" import { Global } from "@opencode-ai/core/global" -import { Instance } from "../project/instance" +import { InstanceLayer } from "@/project/instance-layer" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Database } from "@/storage/db" import { eq } from "drizzle-orm" @@ -159,7 +160,12 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service + | AppFileSystem.Service + | Path.Path + | ChildProcessSpawner.ChildProcessSpawner + | Git.Service + | Project.Service + | InstanceStore.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -169,6 +175,7 @@ export const layer: Layer.Layer< const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const gitSvc = yield* Git.Service const project = yield* Project.Service + const store = yield* InstanceStore.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -251,13 +258,10 @@ export const layer: Layer.Layer< return } - const booted = yield* Effect.promise(() => - Instance.provide({ - directory: info.directory, - fn: () => undefined, - }) - .then(() => true) - .catch((error) => { + const booted = yield* store.load({ directory: info.directory }).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.sync(() => { const message = errorMessage(error) log.error("worktree bootstrap failed", { directory: info.directory, message }) GlobalBus.emit("event", { @@ -268,6 +272,7 @@ export const layer: Layer.Layer< }) return false }), + ), ) if (!booted) return @@ -579,7 +584,7 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( +export const appLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Project.defaultLayer), @@ -587,4 +592,6 @@ export const defaultLayer = layer.pipe( Layer.provide(NodePath.layer), ) +export const defaultLayer = appLayer.pipe(Layer.provide(InstanceLayer.layer)) + export * as Worktree from "." diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index bce5e94598..9a92fc5072 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -3,6 +3,7 @@ import { ACP } from "../../src/acp/agent" import type { AgentSideConnection } from "@agentclientprotocol/sdk" import type { Event, EventMessagePartUpdated, ToolStatePending, ToolStateRunning } from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { tmpdir } from "../fixture/fixture" type SessionUpdateParams = Parameters[0] @@ -262,7 +263,7 @@ function createFakeAgent() { describe("acp.agent event subscription", () => { test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, updates, stop } = createFakeAgent() @@ -297,7 +298,7 @@ describe("acp.agent event subscription", () => { test("does not emit user_message_chunk for live prompt parts", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -337,7 +338,7 @@ describe("acp.agent event subscription", () => { test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, chunks, stop } = createFakeAgent() @@ -389,7 +390,7 @@ describe("acp.agent event subscription", () => { test("does not create additional event subscriptions on repeated loadSession()", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, calls, stop } = createFakeAgent() @@ -411,7 +412,7 @@ describe("acp.agent event subscription", () => { test("permission.asked events are handled and replied", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const permissionReplies: string[] = [] @@ -450,7 +451,7 @@ describe("acp.agent event subscription", () => { test("permission prompt on session A does not block message updates for session B", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const permissionReplies: string[] = [] @@ -537,7 +538,7 @@ describe("acp.agent event subscription", () => { test("streams running bash output snapshots and de-dupes identical snapshots", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -571,7 +572,7 @@ describe("acp.agent event subscription", () => { test("emits synthetic pending before first running update for any tool", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() @@ -616,7 +617,7 @@ describe("acp.agent event subscription", () => { test("does not emit duplicate synthetic pending after replayed running tool", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop, sdk } = createFakeAgent() @@ -675,7 +676,7 @@ describe("acp.agent event subscription", () => { test("clears bash snapshot marker on pending state", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { agent, controller, sessionUpdates, stop } = createFakeAgent() diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 44ed0692a4..6996e54b47 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -3,6 +3,7 @@ import { Effect } from "effect" import path from "path" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Agent } from "../../src/agent/agent" import { Permission } from "../../src/permission" import { Global } from "@opencode-ai/core/global" @@ -23,7 +24,7 @@ afterEach(async () => { test("returns default native agents when no config", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agents = await load(tmp.path, (svc) => svc.list()) @@ -41,7 +42,7 @@ test("returns default native agents when no config", async () => { test("build agent has correct default properties", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -56,7 +57,7 @@ test("build agent has correct default properties", async () => { test("plan agent denies edits except .opencode/plans/*", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const plan = await load(tmp.path, (svc) => svc.get("plan")) @@ -71,7 +72,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => { test("explore agent denies edit and write", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -87,7 +88,7 @@ test("explore agent denies edit and write", async () => { test("explore agent asks for external directories and allows whitelisted external paths", async () => { const { Truncate } = await import("../../src/tool/truncate") await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -103,7 +104,7 @@ test("explore agent asks for external directories and allows whitelisted externa test("general agent denies todo tools", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const general = await load(tmp.path, (svc) => svc.get("general")) @@ -117,7 +118,7 @@ test("general agent denies todo tools", async () => { test("compaction agent denies all permissions", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const compaction = await load(tmp.path, (svc) => svc.get("compaction")) @@ -143,7 +144,7 @@ test("custom agent from config creates new agent", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const custom = await load(tmp.path, (svc) => svc.get("my_custom_agent")) @@ -172,7 +173,7 @@ test("custom agent config overrides native agent properties", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -195,7 +196,7 @@ test("agent disable removes agent from list", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -221,7 +222,7 @@ test("agent permission config merges with defaults", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -242,7 +243,7 @@ test("global permission config applies to all agents", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -261,7 +262,7 @@ test("agent steps/maxSteps config sets steps property", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -280,7 +281,7 @@ test("agent mode can be overridden", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const explore = await load(tmp.path, (svc) => svc.get("explore")) @@ -297,7 +298,7 @@ test("agent name can be overridden", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -314,7 +315,7 @@ test("agent prompt can be set from config", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -334,7 +335,7 @@ test("unknown agent properties are placed into options", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -357,7 +358,7 @@ test("agent options merge correctly", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -382,7 +383,7 @@ test("multiple custom agents can be defined", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agentA = await load(tmp.path, (svc) => svc.get("agent_a")) @@ -411,7 +412,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const names = (await load(tmp.path, (svc) => svc.list())).map((a) => a.name) @@ -423,7 +424,7 @@ test("Agent.list keeps the default agent first and sorts the rest by name", asyn test("Agent.get returns undefined for non-existent agent", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistent = await load(tmp.path, (svc) => svc.get("does_not_exist")) @@ -434,7 +435,7 @@ test("Agent.get returns undefined for non-existent agent", async () => { test("default permission includes doom_loop and external_directory as ask", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -446,7 +447,7 @@ test("default permission includes doom_loop and external_directory as ask", asyn test("webfetch is allowed by default", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -468,7 +469,7 @@ test("legacy tools config converts to permissions", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -490,7 +491,7 @@ test("legacy tools config maps write/edit/patch to edit permission", async () => }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -508,7 +509,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -521,7 +522,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally test("global tmp directory children are allowed for external_directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -546,7 +547,7 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -569,7 +570,7 @@ test("explicit Truncate.GLOB deny is respected", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -601,7 +602,7 @@ description: Permission skill. process.env.OPENCODE_TEST_HOME = tmp.path try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const build = await load(tmp.path, (svc) => svc.get("build")) @@ -617,7 +618,7 @@ description: Permission skill. test("defaultAgent returns build when no default_agent config", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -632,7 +633,7 @@ test("defaultAgent respects default_agent config set to plan", async () => { default_agent: "plan", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -652,7 +653,7 @@ test("defaultAgent respects default_agent config set to custom agent with mode a }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -667,7 +668,7 @@ test("defaultAgent throws when default_agent points to subagent", async () => { default_agent: "explore", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "explore" is a subagent') @@ -681,7 +682,7 @@ test("defaultAgent throws when default_agent points to hidden agent", async () = default_agent: "compaction", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow('default agent "compaction" is hidden') @@ -695,7 +696,7 @@ test("defaultAgent throws when default_agent points to non-existent agent", asyn default_agent: "does_not_exist", }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(load(tmp.path, (svc) => svc.defaultAgent())).rejects.toThrow( @@ -713,7 +714,7 @@ test("defaultAgent returns plan when build is disabled and default_agent not set }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agent = await load(tmp.path, (svc) => svc.defaultAgent()) @@ -732,7 +733,7 @@ test("defaultAgent throws when all primary agents are disabled", async () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // build and plan are disabled, no primary-capable agents remain diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 89e8a66407..72e538aa3a 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "url" import { AppRuntime } from "../../src/effect/app-runtime" import { Agent } from "../../src/agent/agent" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" afterEach(async () => { @@ -39,7 +40,7 @@ test("plugin-registered agents appear in Agent.list", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) diff --git a/packages/opencode/test/bus/bus-integration.test.ts b/packages/opencode/test/bus/bus-integration.test.ts index 7e2138ea81..3e3d7a3e90 100644 --- a/packages/opencode/test/bus/bus-integration.test.ts +++ b/packages/opencode/test/bus/bus-integration.test.ts @@ -3,12 +3,13 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = BusEvent.define("test.integration", Schema.Struct({ value: Schema.Number })) function withInstance(directory: string, fn: () => Promise) { - return Instance.provide({ directory, fn }) + return WithInstance.provide({ directory, fn }) } describe("Bus integration: acquireRelease subscriber pattern", () => { diff --git a/packages/opencode/test/bus/bus.test.ts b/packages/opencode/test/bus/bus.test.ts index b24b79b33b..876cb1ed74 100644 --- a/packages/opencode/test/bus/bus.test.ts +++ b/packages/opencode/test/bus/bus.test.ts @@ -3,6 +3,7 @@ import { Schema } from "effect" import { Bus } from "../../src/bus" import { BusEvent } from "../../src/bus/bus-event" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" const TestEvent = { @@ -11,7 +12,7 @@ const TestEvent = { } function withInstance(directory: string, fn: () => Promise) { - return Instance.provide({ directory, fn }) + return WithInstance.provide({ directory, fn }) } describe("Bus", () => { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9c4cbd788c..0a522b0850 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -7,6 +7,7 @@ import { ConfigParse } from "../../src/config/parse" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Auth } from "../../src/auth" import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" @@ -113,7 +114,7 @@ async function check(map: (dir: string) => string) { $schema: "https://opencode.ai/config.json", snapshot: false, }) - await Instance.provide({ + await WithInstance.provide({ directory: map(tmp.path), fn: async () => { const cfg = await load() @@ -131,7 +132,7 @@ async function check(map: (dir: string) => string) { test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -150,7 +151,7 @@ test("loads JSON config file", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -169,7 +170,7 @@ test("loads shell config field", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -191,7 +192,7 @@ test("updates config and preserves empty shell sentinel", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await save({ shell: "" }) @@ -269,7 +270,7 @@ test("loads formatter boolean config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -287,7 +288,7 @@ test("loads lsp boolean config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -324,7 +325,7 @@ test("ignores legacy tui keys in opencode config", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -349,7 +350,7 @@ test("loads JSONC config file", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -377,7 +378,7 @@ test("jsonc overrides json in the same directory", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -400,7 +401,7 @@ test("handles environment variable substitution", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -432,7 +433,7 @@ test("preserves env variables when adding $schema to config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -529,7 +530,7 @@ test("handles file inclusion substitution", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -548,7 +549,7 @@ test("handles file inclusion with replacement tokens", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -604,7 +605,7 @@ test("handles agent configuration", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -635,7 +636,7 @@ test("treats agent variant as model-scoped setting (not provider option)", async }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -665,7 +666,7 @@ test("handles command configuration", async () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -690,7 +691,7 @@ test("migrates autoshare to share field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -717,7 +718,7 @@ test("migrates mode field to agent field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -749,7 +750,7 @@ Test agent prompt`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -782,7 +783,7 @@ Ordered permissions`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -820,7 +821,7 @@ Nested agent prompt`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -869,7 +870,7 @@ Nested command template`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -914,7 +915,7 @@ Nested command template`, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -934,7 +935,7 @@ Nested command template`, test("updates config and writes to file", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const newConfig = { model: "updated/model" } @@ -948,7 +949,7 @@ test("updates config and writes to file", async () => { test("gets config directories", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const dirs = await listDirs() @@ -978,7 +979,7 @@ test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", as process.env.OPENCODE_CONFIG_DIR = tmp.extra try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await load() @@ -1013,7 +1014,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { ) try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(testLayer))) @@ -1146,7 +1147,7 @@ Helper subagent prompt`, ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1185,7 +1186,7 @@ test("merges instructions arrays from global and local configs", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1224,7 +1225,7 @@ test("deduplicates duplicate instructions from global and local configs", async }, }) - await Instance.provide({ + await WithInstance.provide({ directory: path.join(tmp.path, "project"), fn: async () => { const config = await load() @@ -1359,7 +1360,7 @@ test("migrates legacy tools config to permissions - allow", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1390,7 +1391,7 @@ test("migrates legacy tools config to permissions - deny", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1420,7 +1421,7 @@ test("migrates legacy write tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1452,7 +1453,7 @@ test("managed settings override user settings", async () => { share: "disabled", }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1480,7 +1481,7 @@ test("managed settings override project settings", async () => { disabled_providers: ["openai"], }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1500,7 +1501,7 @@ test("missing managed settings file is not an error", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1527,7 +1528,7 @@ test("migrates legacy edit tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1556,7 +1557,7 @@ test("migrates legacy patch tool to edit permission", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1588,7 +1589,7 @@ test("migrates mixed legacy tools config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1623,7 +1624,7 @@ test("merges legacy tools with existing permission config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1660,7 +1661,7 @@ test("permission config preserves user key order", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1743,7 +1744,7 @@ test("project config can override MCP server enabled status", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1799,7 +1800,7 @@ test("MCP config deep merges preserving base config properties", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -1850,7 +1851,7 @@ test("local .opencode config can override MCP from project config", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2139,7 +2140,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2170,7 +2171,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const directories = await listDirs() @@ -2194,7 +2195,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { try { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Should still get default config (from global or defaults) @@ -2236,7 +2237,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // The relative instruction should be skipped without error @@ -2296,7 +2297,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path - await Instance.provide({ + await WithInstance.provide({ directory: projectTmp.path, fn: async () => { const config = await load() @@ -2331,7 +2332,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { try { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -2365,7 +2366,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { }) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index ddd10f2e06..10a05e3b1e 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -14,6 +14,7 @@ import { Database } from "@/storage/db" import { ProjectID } from "@/project/schema" import { ProjectTable } from "@/project/project.sql" import { Instance } from "@/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import { SessionID, MessageID, PartID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" @@ -101,7 +102,7 @@ afterEach(async () => { async function withInstance(fn: (dir: string) => T | Promise) { await using tmp = await tmpdir({ git: true }) - return Instance.provide({ + return WithInstance.provide({ directory: tmp.path, fn: () => fn(tmp.path), }) diff --git a/packages/opencode/test/file/fsmonitor.test.ts b/packages/opencode/test/file/fsmonitor.test.ts index 699e713c22..f345cd0850 100644 --- a/packages/opencode/test/file/fsmonitor.test.ts +++ b/packages/opencode/test/file/fsmonitor.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import path from "path" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { provideInstance, tmpdir } from "../fixture/fixture" const run = (eff: Effect.Effect) => @@ -30,7 +31,7 @@ describe("file fsmonitor", () => { const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() expect(before.exitCode).not.toBe(0) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await status() @@ -55,7 +56,7 @@ describe("file fsmonitor", () => { const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow() expect(before.exitCode).not.toBe(0) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await read("tracked.txt") diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index bf5e7a175f..cdd2e211c2 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -5,6 +5,7 @@ import path from "path" import fs from "fs/promises" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" @@ -28,7 +29,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, "Hello World", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -41,7 +42,7 @@ describe("file/index Filesystem patterns", () => { test("reads with Filesystem.exists() check", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Non-existent file should return empty content @@ -57,7 +58,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, " content with spaces \n\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -71,7 +72,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "empty.txt") await fs.writeFile(filepath, "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("empty.txt") @@ -86,7 +87,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "multiline.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("multiline.txt") @@ -103,7 +104,7 @@ describe("file/index Filesystem patterns", () => { const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) await fs.writeFile(filepath, binaryContent) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("image.png") @@ -120,7 +121,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "binary.so") await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("binary.so") @@ -137,7 +138,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.json") await fs.writeFile(filepath, '{"key": "value"}', "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await Filesystem.mimeType(filepath)).toContain("application/json") @@ -161,7 +162,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, `test.${ext}`) await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await Filesystem.mimeType(filepath)).toContain(mime) @@ -175,7 +176,7 @@ describe("file/index Filesystem patterns", () => { test("reads .gitignore via Filesystem.exists() and readText()", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const gitignorePath = path.join(tmp.path, ".gitignore") @@ -193,7 +194,7 @@ describe("file/index Filesystem patterns", () => { test("reads .ignore file similarly", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ignorePath = path.join(tmp.path, ".ignore") @@ -208,7 +209,7 @@ describe("file/index Filesystem patterns", () => { test("handles missing .gitignore gracefully", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const gitignorePath = path.join(tmp.path, ".gitignore") @@ -226,7 +227,7 @@ describe("file/index Filesystem patterns", () => { test("reads untracked files via Filesystem.readText()", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const untrackedPath = path.join(tmp.path, "untracked.txt") @@ -247,7 +248,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "readonly.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistentPath = path.join(tmp.path, "does-not-exist.txt") @@ -264,7 +265,7 @@ describe("file/index Filesystem patterns", () => { test("handles errors in Filesystem.readArrayBuffer()", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nonExistentPath = path.join(tmp.path, "does-not-exist.bin") @@ -279,7 +280,7 @@ describe("file/index Filesystem patterns", () => { const _filepath = path.join(tmp.path, "broken.png") // Don't create the file - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // read() handles missing images gracefully @@ -297,7 +298,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.ts") await fs.writeFile(filepath, "export const value = 1", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.ts") @@ -312,7 +313,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.mts") await fs.writeFile(filepath, "export const value = 1", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.mts") @@ -327,7 +328,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.sh") await fs.writeFile(filepath, "#!/usr/bin/env bash\necho hello", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.sh") @@ -342,7 +343,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "Dockerfile") await fs.writeFile(filepath, "FROM alpine:3.20", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("Dockerfile") @@ -357,7 +358,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.txt") await fs.writeFile(filepath, "simple text", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.txt") @@ -372,7 +373,7 @@ describe("file/index Filesystem patterns", () => { const filepath = path.join(tmp.path, "test.jpg") await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("test.jpg") @@ -387,7 +388,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../outside.txt")).rejects.toThrow("Access denied") @@ -398,7 +399,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../outside.txt")).rejects.toThrow("Access denied") @@ -416,7 +417,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.writeFile(filepath, "modified\nextra line\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -433,7 +434,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "new.txt"), "line1\nline2\nline3\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -454,7 +455,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.rm(filepath) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -477,7 +478,7 @@ describe("file/index Filesystem patterns", () => { await fs.rm(path.join(tmp.path, "remove.txt")) await fs.writeFile(path.join(tmp.path, "brand-new.txt"), "hello\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -491,7 +492,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty for non-git project", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -503,7 +504,7 @@ describe("file/index Filesystem patterns", () => { test("returns empty for clean repo", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -526,7 +527,7 @@ describe("file/index Filesystem patterns", () => { for (let i = 0; i < 512; i++) modified[i] = i % 256 await fs.writeFile(filepath, modified) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await status() @@ -547,7 +548,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "file.txt"), "content", "utf-8") await fs.writeFile(path.join(tmp.path, "subdir", "nested.txt"), "nested", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -571,7 +572,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "zz.txt"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "aa.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -596,7 +597,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, ".DS_Store"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "visible.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -615,7 +616,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "main.ts"), "code", "utf-8") await fs.mkdir(path.join(tmp.path, "build")) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -635,7 +636,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "sub", "a.txt"), "", "utf-8") await fs.writeFile(path.join(tmp.path, "sub", "b.txt"), "", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list("sub") @@ -650,7 +651,7 @@ describe("file/index Filesystem patterns", () => { test("throws for paths outside project directory", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(list("../outside")).rejects.toThrow("Access denied") @@ -662,7 +663,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir() await fs.writeFile(path.join(tmp.path, "file.txt"), "hi", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const nodes = await list() @@ -692,7 +693,7 @@ describe("file/index Filesystem patterns", () => { test("empty query returns files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -706,7 +707,7 @@ describe("file/index Filesystem patterns", () => { test("search works before explicit init", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await search({ query: "main", type: "file" }) @@ -718,7 +719,7 @@ describe("file/index Filesystem patterns", () => { test("empty query returns dirs sorted with hidden last", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -738,7 +739,7 @@ describe("file/index Filesystem patterns", () => { test("fuzzy matches file names", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -752,7 +753,7 @@ describe("file/index Filesystem patterns", () => { test("type filter returns only files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -769,7 +770,7 @@ describe("file/index Filesystem patterns", () => { test("type filter returns only directories", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -786,7 +787,7 @@ describe("file/index Filesystem patterns", () => { test("respects limit", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -800,7 +801,7 @@ describe("file/index Filesystem patterns", () => { test("query starting with dot prefers hidden files", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -815,7 +816,7 @@ describe("file/index Filesystem patterns", () => { test("search refreshes after init when files change", async () => { await using tmp = await setupSearchableRepo() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -839,7 +840,7 @@ describe("file/index Filesystem patterns", () => { await $`git commit -m "add file"`.cwd(tmp.path).quiet() await fs.writeFile(filepath, "modified content\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("file.txt") @@ -863,7 +864,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(filepath, "after\n", "utf-8") await $`git add .`.cwd(tmp.path).quiet() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("staged.txt") @@ -880,7 +881,7 @@ describe("file/index Filesystem patterns", () => { await $`git add .`.cwd(tmp.path).quiet() await $`git commit -m "add file"`.cwd(tmp.path).quiet() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("clean.txt") @@ -900,7 +901,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8") await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: one.path, fn: async () => { await init() @@ -911,7 +912,7 @@ describe("file/index Filesystem patterns", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: two.path, fn: async () => { await init() @@ -927,7 +928,7 @@ describe("file/index Filesystem patterns", () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() @@ -941,7 +942,7 @@ describe("file/index Filesystem patterns", () => { await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8") await fs.rm(path.join(tmp.path, "before.ts")) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await init() diff --git a/packages/opencode/test/file/path-traversal.test.ts b/packages/opencode/test/file/path-traversal.test.ts index 3a5ce2323e..5b59929ea5 100644 --- a/packages/opencode/test/file/path-traversal.test.ts +++ b/packages/opencode/test/file/path-traversal.test.ts @@ -5,6 +5,7 @@ import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import { File } from "../../src/file" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { containsPath } from "../../src/project/instance-context" import { provideInstance, tmpdir } from "../fixture/fixture" @@ -55,7 +56,7 @@ describe("File.read path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("../../../etc/passwd")).rejects.toThrow("Access denied: path escapes project directory") @@ -66,7 +67,7 @@ describe("File.read path traversal protection", () => { test("rejects deeply nested traversal", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(read("src/nested/../../../../../../../etc/passwd")).rejects.toThrow( @@ -83,7 +84,7 @@ describe("File.read path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await read("valid.txt") @@ -97,7 +98,7 @@ describe("File.list path traversal protection", () => { test("rejects ../ traversal attempting to list /etc", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await expect(list("../../../etc")).rejects.toThrow("Access denied: path escapes project directory") @@ -112,7 +113,7 @@ describe("File.list path traversal protection", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await list("subdir") @@ -126,7 +127,7 @@ describe("containsPath", () => { test("returns true for path inside directory", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath(path.join(tmp.path, "foo.txt"), Instance.current)).toBe(true) @@ -140,7 +141,7 @@ describe("containsPath", () => { const subdir = path.join(tmp.path, "packages", "lib") await fs.mkdir(subdir, { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: subdir, fn: () => { // .opencode at worktree root, but we're running from packages/lib @@ -156,7 +157,7 @@ describe("containsPath", () => { test("returns false for path outside both directory and worktree", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath("/etc/passwd", Instance.current)).toBe(false) @@ -168,7 +169,7 @@ describe("containsPath", () => { test("returns false for path with .. escaping worktree", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(containsPath(path.join(tmp.path, "..", "escape.txt"), Instance.current)).toBe(false) @@ -179,7 +180,7 @@ describe("containsPath", () => { test("handles directory === worktree (running from repo root)", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { expect(Instance.directory).toBe(Instance.worktree) @@ -192,7 +193,7 @@ describe("containsPath", () => { test("non-git project does not allow arbitrary paths via worktree='/'", async () => { await using tmp = await tmpdir() // no git: true - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => { // worktree is "/" for non-git projects, but containsPath should NOT allow all paths diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index e183f673f0..7e47c51351 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -9,6 +9,7 @@ import { Config } from "@/config/config" import { FileWatcher } from "../../src/file/watcher" import { Git } from "../../src/git" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip @@ -28,7 +29,7 @@ type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } /** Run `body` with a live FileWatcher service. */ function withWatcher(directory: string, body: Effect.Effect) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { const layer: Layer.Layer = FileWatcher.layer.pipe( @@ -193,7 +194,7 @@ describeWatcher("FileWatcher", () => { await withWatcher(tmp.path, Effect.void) // Now write a file — no watcher should be listening - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: () => Effect.runPromise( diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 970365f533..d47620f623 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -25,8 +25,9 @@ const runTestInstanceStore = (fn: (store: InstanceStore.Interface) => Effect. testInstanceRuntime.runPromise(InstanceStore.Service.use(fn)) export async function provideTestInstance(input: { directory: string; init?: Effect.Effect; fn: () => R }) { - const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory, init: input.init })) + const ctx = await runTestInstanceStore((store) => store.load({ directory: input.directory })) try { + if (input.init) await testInstanceRuntime.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) return await Instance.restore(ctx, () => input.fn()) } finally { await runTestInstanceStore((store) => store.dispose(ctx)) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index f3c7893d97..7d9f5a7155 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "../fixture/fixture" import { LSPClient } from "@/lsp/client" import * as LSPServer from "@/lsp/server" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" function spawnFakeServer() { @@ -25,7 +26,7 @@ describe("LSPClient interop", () => { test("handles workspace/workspaceFolders request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -48,7 +49,7 @@ describe("LSPClient interop", () => { test("handles client/registerCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -71,7 +72,7 @@ describe("LSPClient interop", () => { test("handles client/unregisterCapability request", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -94,7 +95,7 @@ describe("LSPClient interop", () => { test("initialize does not overclaim unsupported diagnostics capabilities", async () => { const handle = spawnFakeServer() as any - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -121,7 +122,7 @@ describe("LSPClient interop", () => { gamma: true, } - const client = await Instance.provide({ + const client = await WithInstance.provide({ directory: process.cwd(), fn: () => LSPClient.create({ @@ -150,7 +151,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "first\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -193,7 +194,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -239,7 +240,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.ts") await Bun.write(file, "const x = 1\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -286,7 +287,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -334,7 +335,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -387,7 +388,7 @@ describe("LSPClient interop", () => { await Bun.write(file, "class C {}\n") await Bun.write(related, "class D {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ @@ -451,7 +452,7 @@ describe("LSPClient interop", () => { const file = path.join(tmp.path, "client.cs") await Bun.write(file, "class C {}\n") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const client = await LSPClient.create({ diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 175717d056..5bc8f803d2 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -48,6 +48,7 @@ beforeEach(() => { const { MCP } = await import("../../src/mcp/index") const { AppRuntime } = await import("../../src/effect/app-runtime") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") const service = MCP.Service as unknown as Effect.Effect @@ -73,7 +74,7 @@ test("headers are passed to transports when oauth is enabled (default)", async ( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Trigger MCP initialization - it will fail to connect but we can check the transport options @@ -112,7 +113,7 @@ test("headers are passed to transports when oauth is enabled (default)", async ( test("headers are passed to transports when oauth is explicitly disabled", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { transportCalls.length = 0 @@ -150,7 +151,7 @@ test("headers are passed to transports when oauth is explicitly disabled", async test("no requestInit when headers are not provided", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { transportCalls.length = 0 diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 2ba487f3f5..10547c9f08 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -172,6 +172,7 @@ beforeEach(() => { // Import after mocks const { MCP } = await import("../../src/mcp/index") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") // --- Helper --- @@ -193,7 +194,7 @@ function withInstance( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise(MCP.Service.use(fn).pipe(Effect.provide(MCP.defaultLayer))) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 8b29f6d1e3..3cf6774215 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -112,6 +112,7 @@ beforeEach(() => { // Import modules after mocking const { MCP } = await import("../../src/mcp/index") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") test("first connect to OAuth server shows needs_auth instead of failed", async () => { @@ -132,7 +133,7 @@ test("first connect to OAuth server shows needs_auth instead of failed", async ( }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await Effect.runPromise( @@ -162,7 +163,7 @@ test("state() generates a new state when none is saved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const auth = await Effect.runPromise( @@ -203,7 +204,7 @@ test("state() returns existing state when one is saved", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const auth = await Effect.runPromise( @@ -252,7 +253,7 @@ test("authenticate() stores a connected client when auth completes without redir }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Effect.runPromise( diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 3a6df02a15..20cb90a18e 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -106,6 +106,7 @@ const { AppRuntime } = await import("../../src/effect/app-runtime") const { Bus } = await import("../../src/bus") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { Instance } = await import("../../src/project/instance") +const { WithInstance } = await import("../../src/project/with-instance") const { tmpdir } = await import("../fixture/fixture") const service = MCP.Service as unknown as Effect.Effect @@ -127,7 +128,7 @@ test("BrowserOpenFailed event is published when open() throws", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = true @@ -183,7 +184,7 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () = }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = false @@ -237,7 +238,7 @@ test("open() is called with the authorization URL", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { openShouldFail = false diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index d4f9192c76..64b93bb8bc 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, test, expect } from "bun:test" import { Permission } from "../src/permission" import { Config } from "@/config/config" import { Instance } from "../src/project/instance" +import { WithInstance } from "../src/project/with-instance" import { disposeAllInstances, tmpdir } from "./fixture/fixture" import { AppRuntime } from "../src/effect/app-runtime" @@ -158,7 +159,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -183,7 +184,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -208,7 +209,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -235,7 +236,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -273,7 +274,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() @@ -304,7 +305,7 @@ describe("permission.task with real config files", () => { }, }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const config = await load() diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 4d66784d81..1c3d6fc563 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -6,6 +6,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { disposeAllInstances, @@ -1006,7 +1007,7 @@ it.live("pending permission rejects on instance dispose", () => expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) yield* Effect.promise(() => - Instance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => void InstanceRuntime.disposeInstance(Instance.current) }), ) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/project/instance-bootstrap-regression.test.ts b/packages/opencode/test/project/instance-bootstrap-regression.test.ts index bb8d43e015..c01450549b 100644 --- a/packages/opencode/test/project/instance-bootstrap-regression.test.ts +++ b/packages/opencode/test/project/instance-bootstrap-regression.test.ts @@ -5,6 +5,7 @@ import path from "node:path" import { pathToFileURL } from "node:url" import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { InstanceMiddleware } from "../../src/server/routes/instance/middleware" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -50,7 +51,7 @@ async function bootstrapFixture() { test("Instance.provide runs InstanceBootstrap before fn (boundary invariant)", async () => { await using tmp = await bootstrapFixture() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => "ok", }) diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index bc8809af9c..655e381b9a 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -5,17 +5,23 @@ import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +let bootstrapRun: Effect.Effect = Effect.void +const noopBootstrap = Layer.succeed( + InstanceBootstrap.Service, + InstanceBootstrap.Service.of({ run: Effect.suspend(() => bootstrapRun) }), +) const it = testEffect( Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)), ) afterEach(async () => { + bootstrapRun = Effect.void await disposeAllInstances() }) @@ -32,18 +38,16 @@ describe("InstanceStore", () => { }), ) - it.live("runs load init with InstanceRef provided", () => + it.live("runs bootstrap with InstanceRef provided", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const store = yield* InstanceStore.Service let initializedDirectory: string | undefined - yield* store.load({ - directory: dir, - init: Effect.gen(function* () { - initializedDirectory = (yield* InstanceRef)?.directory - }), + bootstrapRun = Effect.gen(function* () { + initializedDirectory = (yield* InstanceRef)?.directory }) + yield* store.load({ directory: dir }) expect(initializedDirectory).toBe(dir) expect(() => Instance.current).toThrow() @@ -56,18 +60,11 @@ describe("InstanceStore", () => { const store = yield* InstanceStore.Service let initialized = 0 - const first = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), - }) - const second = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), + bootstrapRun = Effect.sync(() => { + initialized++ }) + const first = yield* store.load({ directory: dir }) + const second = yield* store.load({ directory: dir }) expect(second).toBe(first) expect(initialized).toBe(1) @@ -82,27 +79,19 @@ describe("InstanceStore", () => { const release = Promise.withResolvers() let initialized = 0 - const first = yield* store - .load({ - directory: dir, - init: Effect.promise(async () => { - initialized++ - started.resolve() - await release.promise - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.promise(async () => { + initialized++ + started.resolve() + await release.promise + }) + const first = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) yield* Effect.promise(() => started.promise) - const second = yield* store - .load({ - directory: dir, - init: Effect.sync(() => { - initialized++ - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.sync(() => { + initialized++ + }) + const second = yield* store.load({ directory: dir }).pipe(Effect.forkScoped) expect(initialized).toBe(1) release.resolve() @@ -119,27 +108,21 @@ describe("InstanceStore", () => { const store = yield* InstanceStore.Service let attempts = 0 - const failed = yield* store - .load({ - directory: dir, - init: Effect.sync(() => { - attempts++ - throw new Error("init failed") - }), - }) - .pipe( - Effect.as(false), - Effect.catchCause(() => Effect.succeed(true)), - ) + bootstrapRun = Effect.sync(() => { + attempts++ + throw new Error("init failed") + }) + const failed = yield* store.load({ directory: dir }).pipe( + Effect.as(false), + Effect.catchCause(() => Effect.succeed(true)), + ) expect(failed).toBe(true) - const ctx = yield* store.load({ - directory: dir, - init: Effect.sync(() => { - attempts++ - }), + bootstrapRun = Effect.sync(() => { + attempts++ }) + const ctx = yield* store.load({ directory: dir }) expect(ctx.directory).toBe(dir) expect(attempts).toBe(2) @@ -173,15 +156,11 @@ describe("InstanceStore", () => { yield* Effect.addFinalizer(() => Effect.sync(off)) const first = yield* store.load({ directory: dir }) - const reload = yield* store - .reload({ - directory: dir, - init: Effect.promise(async () => { - reloading.resolve() - await releaseReload.promise - }), - }) - .pipe(Effect.forkScoped) + bootstrapRun = Effect.promise(async () => { + reloading.resolve() + await releaseReload.promise + }) + const reload = yield* store.reload({ directory: dir }).pipe(Effect.forkScoped) yield* Effect.promise(() => reloading.promise) const staleDispose = yield* store.dispose(first).pipe(Effect.forkScoped) @@ -242,12 +221,12 @@ describe("InstanceStore", () => { }), ) - it.live("keeps Instance.provide as the legacy ALS wrapper", () => + it.live("provides legacy Promise callers with instance ALS", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const directory = yield* Effect.promise(() => - Instance.provide({ + WithInstance.provide({ directory: dir, fn: () => Instance.directory, }), @@ -258,21 +237,4 @@ describe("InstanceStore", () => { }), ) - it.live("does not install legacy ALS around Effect init", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - - const directory = yield* Effect.promise(() => - Instance.provide({ - directory: dir, - init: Effect.sync(() => { - expect(() => Instance.current).toThrow() - }), - fn: () => Instance.directory, - }), - ) - - expect(directory).toBe(dir) - }), - ) }) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 0d0e46fe48..6fb0e251d3 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -7,6 +7,7 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { AppRuntime } from "../../src/effect/app-runtime" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { GlobalBus } from "../../src/bus/global" import { Vcs } from "@/project/vcs" @@ -18,7 +19,7 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe // --------------------------------------------------------------------------- async function withVcs(directory: string, body: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { await AppRuntime.runPromise( @@ -36,7 +37,7 @@ async function withVcs(directory: string, body: () => Promise) { } function withVcsOnly(directory: string, body: () => Promise) { - return Instance.provide({ + return WithInstance.provide({ directory, fn: async () => { await AppRuntime.runPromise( diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index 60c66981d5..a89fda6ca5 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,6 +5,7 @@ import path from "path" import { Cause, Effect, Exit, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture" @@ -138,7 +139,7 @@ describe("Worktree", () => { expect(props.branch).toBe(info.branch) yield* Effect.promise(() => - Instance.provide({ + WithInstance.provide({ directory: info.directory, fn: () => InstanceRuntime.disposeInstance(Instance.current), }), @@ -163,7 +164,7 @@ describe("Worktree", () => { yield* Effect.promise(() => ready) yield* Effect.promise(() => - Instance.provide({ + WithInstance.provide({ directory: info.directory, fn: () => InstanceRuntime.disposeInstance(Instance.current), }), diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index 43b23dafad..c35a03d78b 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -5,6 +5,7 @@ import { unlink } from "fs/promises" import { ProviderID } from "../../src/provider/schema" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { Env } from "../../src/env" import { Global } from "@opencode-ai/core/global" @@ -43,13 +44,11 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_REGION", "us-east-1") set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -68,13 +67,11 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_REGION", "eu-west-1") set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -123,14 +120,12 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => { }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") set("AWS_BEARER_TOKEN_BEDROCK", "") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1") @@ -169,13 +164,11 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_PROFILE", "default") set("AWS_ACCESS_KEY_ID", "test-key-id") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") @@ -201,12 +194,10 @@ test("Bedrock: includes custom endpoint in options when specified", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe( @@ -234,15 +225,13 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async () ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("AWS_WEB_IDENTITY_TOKEN_FILE", "/var/run/secrets/eks.amazonaws.com/serviceaccount/token") set("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/my-eks-role") set("AWS_PROFILE", "") set("AWS_ACCESS_KEY_ID", "") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1") @@ -277,12 +266,10 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // The model should exist with the us. prefix @@ -314,12 +301,10 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -350,12 +335,10 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined() @@ -386,12 +369,10 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("AWS_PROFILE", "default") - }).pipe(Effect.asVoid), fn: async () => { + set("AWS_PROFILE", "default") const providers = await list() expect(providers[ProviderID.amazonBedrock]).toBeDefined() // Non-prefixed model should still be registered diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index 84478a34c4..8bb3b96347 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -9,6 +9,7 @@ export {} // import { ProviderID, ModelID } from "../../src/provider/schema" // import { tmpdir } from "../fixture/fixture" // import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" // import { Provider } from "@/provider/provider" // import { Env } from "../../src/env" // import { Global } from "@opencode-ai/core/global" @@ -25,7 +26,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-gitlab-token") @@ -56,7 +57,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -95,7 +96,7 @@ export {} // }), // ) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -130,7 +131,7 @@ export {} // }), // ) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "") @@ -162,7 +163,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") @@ -193,7 +194,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "env-token") @@ -216,7 +217,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -252,7 +253,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -277,7 +278,7 @@ export {} // ) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -301,7 +302,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -349,7 +350,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -372,7 +373,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") @@ -396,7 +397,7 @@ export {} // await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" })) // }, // }) -// await Instance.provide({ +// await WithInstance.provide({ // directory: tmp.path, // init: async () => { // Env.set("GITLAB_TOKEN", "test-token") diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 924f42888b..cdb9d20572 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -5,6 +5,7 @@ import path from "path" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { Global } from "@opencode-ai/core/global" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Plugin } from "../../src/plugin/index" import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" @@ -80,12 +81,10 @@ test("provider loaded from env variable", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Provider should retain its connection source even if custom loaders @@ -114,7 +113,7 @@ test("provider loaded from config with apiKey option", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -135,12 +134,10 @@ test("disabled_providers excludes provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, @@ -159,13 +156,11 @@ test("enabled_providers restricts to only listed providers", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() @@ -189,12 +184,10 @@ test("model whitelist filters models for provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -220,12 +213,10 @@ test("model blacklist excludes specific models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -255,12 +246,10 @@ test("custom model alias via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined() @@ -301,7 +290,7 @@ test("custom provider with npm package", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -358,7 +347,7 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -392,12 +381,10 @@ test("env variable takes precedence, config merges options", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "env-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "env-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() // Config options should be merged @@ -418,12 +405,10 @@ test("getModel returns model for valid provider/model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") @@ -445,12 +430,10 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") expect(getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() }, }) @@ -467,7 +450,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() @@ -498,12 +481,10 @@ test("defaultModel returns first available model when no config set", async () = ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await defaultModel() expect(model.providerID).toBeDefined() expect(model.modelID).toBeDefined() @@ -523,12 +504,10 @@ test("defaultModel respects config model setting", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await defaultModel() expect(String(model.providerID)).toBe("anthropic") expect(String(model.modelID)).toBe("claude-sonnet-4-20250514") @@ -565,7 +544,7 @@ test("provider with baseURL from config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -603,7 +582,7 @@ test("model cost defaults to zero when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -638,12 +617,10 @@ test("model options are merged from existing model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.options.customOption).toBe("custom-value") @@ -667,12 +644,10 @@ test("provider removed when all models filtered out", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeUndefined() }, @@ -690,12 +665,10 @@ test("closest finds model by partial match", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const result = await closest(ProviderID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() expect(String(result?.providerID)).toBe("anthropic") @@ -715,7 +688,7 @@ test("closest returns undefined for nonexistent provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const result = await closest(ProviderID.make("nonexistent"), ["model"]) @@ -745,12 +718,10 @@ test("getModel uses realIdByKey for aliased models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined() @@ -791,7 +762,7 @@ test("provider api field sets model api.url", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -831,7 +802,7 @@ test("explicit baseURL overrides api field", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -860,12 +831,10 @@ test("model inherits properties from existing database model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.name).toBe("Custom Name for Sonnet") @@ -888,12 +857,10 @@ test("disabled_providers prevents loading even with env var", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-openai-key") const providers = await list() expect(providers[ProviderID.openai]).toBeUndefined() }, @@ -912,13 +879,11 @@ test("enabled_providers with empty array allows no providers", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-api-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(Object.keys(providers).length).toBe(0) }, @@ -942,12 +907,10 @@ test("whitelist and blacklist can be combined", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() const models = Object.keys(providers[ProviderID.anthropic].models) @@ -984,7 +947,7 @@ test("model modalities default correctly", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1027,7 +990,7 @@ test("model with custom cost values", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1051,12 +1014,10 @@ test("getSmallModel returns appropriate small model", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(model?.id).toContain("haiku") @@ -1076,12 +1037,10 @@ test("getSmallModel respects config small_model override", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model = await getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(String(model?.providerID)).toBe("anthropic") @@ -1124,13 +1083,11 @@ test("multiple providers can be configured simultaneously", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeDefined() @@ -1169,7 +1126,7 @@ test("provider with custom npm package", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1203,12 +1160,10 @@ test("model alias name defaults to alias key when id differs", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet") }, @@ -1243,12 +1198,10 @@ test("provider with multiple env var options only includes apiKey when single en ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("MULTI_ENV_KEY_1", "test-key") - }).pipe(Effect.asVoid), fn: async () => { + set("MULTI_ENV_KEY_1", "test-key") const providers = await list() expect(providers[ProviderID.make("multi-env")]).toBeDefined() // When multiple env options exist, key should NOT be auto-set @@ -1285,12 +1238,10 @@ test("provider with single env var includes apiKey automatically", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("SINGLE_ENV_KEY", "my-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("SINGLE_ENV_KEY", "my-api-key") const providers = await list() expect(providers[ProviderID.make("single-env")]).toBeDefined() // Single env option should auto-set key @@ -1322,12 +1273,10 @@ test("model cost overrides existing cost values", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.cost.input).toBe(999) @@ -1372,7 +1321,7 @@ test("completely new provider not in database can be configured", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1401,14 +1350,12 @@ test("disabled_providers and enabled_providers interaction", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic") set("OPENAI_API_KEY", "test-openai") set("GOOGLE_GENERATIVE_AI_API_KEY", "test-google") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() // anthropic: in enabled, not in disabled = allowed expect(providers[ProviderID.anthropic]).toBeDefined() @@ -1446,7 +1393,7 @@ test("model with tool_call false", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1481,7 +1428,7 @@ test("model defaults tool_call to true when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1520,7 +1467,7 @@ test("model headers are preserved", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1559,13 +1506,11 @@ test("provider env fallback - second env var used if first missing", async () => ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { // Only set fallback, not primary set("FALLBACK_KEY", "fallback-api-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() // Provider should load because fallback env var is set expect(providers[ProviderID.make("fallback-env")]).toBeDefined() @@ -1584,12 +1529,10 @@ test("getModel returns consistent results", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const model1 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) const model2 = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model1.providerID).toEqual(model2.providerID) @@ -1625,7 +1568,7 @@ test("provider name defaults to id when not in database", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1645,12 +1588,10 @@ test("ModelNotFoundError includes suggestions for typos", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") try { await getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet expect(true).toBe(false) // Should not reach here @@ -1673,12 +1614,10 @@ test("ModelNotFoundError for provider includes suggestions", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") try { await getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic expect(true).toBe(false) // Should not reach here @@ -1701,7 +1640,7 @@ test("getProvider returns undefined for nonexistent provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const provider = await getProvider(ProviderID.make("nonexistent")) @@ -1721,12 +1660,10 @@ test("getProvider returns provider info", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const provider = await getProvider(ProviderID.anthropic) expect(provider).toBeDefined() expect(String(provider?.id)).toBe("anthropic") @@ -1745,12 +1682,10 @@ test("closest returns undefined when no partial match found", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const result = await closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() }, @@ -1768,12 +1703,10 @@ test("closest checks multiple query terms in order", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") // First term won't match, second will const result = await closest(ProviderID.anthropic, ["nonexistent", "haiku"]) expect(result).toBeDefined() @@ -1808,7 +1741,7 @@ test("model limit defaults to zero when not specified", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -1840,12 +1773,10 @@ test("provider options are deeply merged", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() // Custom options should be merged expect(providers[ProviderID.anthropic].options.timeout).toBe(30000) @@ -1878,12 +1809,10 @@ test("custom model inherits npm package from models.dev provider config", async ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.openai].models["my-custom-model"] expect(model).toBeDefined() @@ -1913,12 +1842,10 @@ test("custom model inherits api.url from models.dev provider", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENROUTER_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENROUTER_API_KEY", "test-api-key") const providers = await list() expect(providers[ProviderID.openrouter]).toBeDefined() @@ -2046,12 +1973,10 @@ test("model variants are generated for reasoning models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() // Claude sonnet 4 has reasoning capability const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] @@ -2084,12 +2009,10 @@ test("model variants can be disabled via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() @@ -2127,12 +2050,10 @@ test("model variants can be customized via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() @@ -2166,12 +2087,10 @@ test("disabled key is stripped from variant config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["max"]).toBeDefined() @@ -2204,12 +2123,10 @@ test("all variants can be disabled via config", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants).toBeDefined() @@ -2242,12 +2159,10 @@ test("variant config merges with generated variants", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("ANTHROPIC_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("ANTHROPIC_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"] expect(model.variants!["high"]).toBeDefined() @@ -2280,12 +2195,10 @@ test("variants filtered in second pass for database models", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("OPENAI_API_KEY", "test-api-key") - }).pipe(Effect.asVoid), fn: async () => { + set("OPENAI_API_KEY", "test-api-key") const providers = await list() const model = providers[ProviderID.openai].models["gpt-5"] expect(model.variants).toBeDefined() @@ -2329,7 +2242,7 @@ test("custom model with variants enabled and disabled", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const providers = await list() @@ -2384,12 +2297,10 @@ test("Google Vertex: retains baseURL for custom proxy", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }).pipe(Effect.asVoid), fn: async () => { + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = await list() expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined() expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1") @@ -2429,12 +2340,10 @@ test("Google Vertex: supports OpenAI compatible models", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { - set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") - }).pipe(Effect.asVoid), fn: async () => { + set("GOOGLE_APPLICATION_CREDENTIALS", "test-creds") const providers = await list() const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"] @@ -2455,14 +2364,12 @@ test("cloudflare-ai-gateway loads with env variables", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() }, @@ -2487,14 +2394,12 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { ) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("CLOUDFLARE_ACCOUNT_ID", "test-account") set("CLOUDFLARE_GATEWAY_ID", "test-gateway") set("CLOUDFLARE_API_TOKEN", "test-token") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined() expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({ @@ -2542,7 +2447,7 @@ test("plugin config providers persist after instance dispose", async () => { }, }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => AppRuntime.runPromise( @@ -2559,7 +2464,7 @@ test("plugin config providers persist after instance dispose", async () => { await disposeAllInstances() - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => list(), }) @@ -2590,13 +2495,11 @@ test("plugin config enabled and disabled providers are honored", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, - init: Effect.promise(async () => { + fn: async () => { set("ANTHROPIC_API_KEY", "test-anthropic-key") set("OPENAI_API_KEY", "test-openai-key") - }).pipe(Effect.asVoid), - fn: async () => { const providers = await list() expect(providers[ProviderID.anthropic]).toBeDefined() expect(providers[ProviderID.openai]).toBeUndefined() @@ -2616,7 +2519,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const none = await Instance.provide({ + const none = await WithInstance.provide({ directory: base.path, fn: async () => paid(await list()), }) @@ -2639,7 +2542,7 @@ test("opencode loader keeps paid models when config apiKey is present", async () }, }) - const keyedCount = await Instance.provide({ + const keyedCount = await WithInstance.provide({ directory: keyed.path, fn: async () => paid(await list()), }) @@ -2660,7 +2563,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }, }) - const none = await Instance.provide({ + const none = await WithInstance.provide({ directory: base.path, fn: async () => paid(await list()), }) @@ -2694,7 +2597,7 @@ test("opencode loader keeps paid models when auth exists", async () => { }), ) - const keyedCount = await Instance.provide({ + const keyedCount = await WithInstance.provide({ directory: keyed.path, fn: async () => paid(await list()), }) diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 9ef9741bad..662042b64c 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { AppRuntime } from "../../src/effect/app-runtime" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import { tmpdir } from "../fixture/fixture" import { setTimeout as sleep } from "node:timers/promises" @@ -10,7 +11,7 @@ describe("pty", () => { test("does not leak output when websocket objects are reused", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -60,7 +61,7 @@ describe("pty", () => { test("does not leak output when Bun recycles websocket objects before re-connect", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -105,7 +106,7 @@ describe("pty", () => { test("treats in-place socket data mutation as the same connection", async () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 3e4d658355..8c5d804b73 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -3,6 +3,7 @@ import { AppRuntime } from "../../src/effect/app-runtime" import { Bus } from "../../src/bus" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import type { PtyID } from "../../src/pty/schema" import { tmpdir } from "../fixture/fixture" @@ -27,7 +28,7 @@ describe("pty", () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -68,7 +69,7 @@ describe("pty", () => { await using dir = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/pty/pty-shell.test.ts b/packages/opencode/test/pty/pty-shell.test.ts index 7b8b4d67ca..00e965d25e 100644 --- a/packages/opencode/test/pty/pty-shell.test.ts +++ b/packages/opencode/test/pty/pty-shell.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { AppRuntime } from "../../src/effect/app-runtime" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Pty } from "../../src/pty" import { Shell } from "../../src/shell/shell" import { tmpdir } from "../fixture/fixture" @@ -17,7 +18,7 @@ describe("pty shell args", () => { "does not add login args to pwsh", async () => { await using dir = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -47,7 +48,7 @@ describe("pty shell args", () => { "adds login args to bash", async () => { await using dir = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( @@ -78,7 +79,7 @@ describe("pty configured shell", () => { await using dir = await tmpdir({ config: { shell: Shell.name(configured) }, }) - await Instance.provide({ + await WithInstance.provide({ directory: dir.path, fn: () => AppRuntime.runPromise( diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 9e577ec3cd..4e2c8ef9bb 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -2,6 +2,7 @@ import { afterEach, expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Question } from "../../src/question" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { QuestionID } from "../../src/question/schema" import { disposeAllInstances, provideInstance, reloadTestInstance, tmpdirScoped } from "../fixture/fixture" @@ -398,7 +399,7 @@ it.live("pending question rejects on instance dispose", () => expect(yield* waitForPending(1).pipe(provideInstance(dir))).toHaveLength(1) yield* Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ) const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index a5ab7b8f36..9368089511 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import z from "zod" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Project } from "@/project/project" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" @@ -28,11 +29,11 @@ describe("session.listGlobal", () => { await using first = await tmpdir({ git: true }) await using second = await tmpdir({ git: true }) - const firstSession = await Instance.provide({ + const firstSession = await WithInstance.provide({ directory: first.path, fn: async () => svc.create({ title: "first-session" }), }) - const secondSession = await Instance.provide({ + const secondSession = await WithInstance.provide({ directory: second.path, fn: async () => svc.create({ title: "second-session" }), }) @@ -58,12 +59,12 @@ describe("session.listGlobal", () => { test("excludes archived sessions by default", async () => { await using tmp = await tmpdir({ git: true }) - const archived = await Instance.provide({ + const archived = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "archived-session" }), }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => svc.setArchived({ sessionID: archived.id, time: Date.now() }), }) @@ -82,12 +83,12 @@ describe("session.listGlobal", () => { test("supports cursor pagination", async () => { await using tmp = await tmpdir({ git: true }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "page-one" }), }) await new Promise((resolve) => setTimeout(resolve, 5)) - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => svc.create({ title: "page-two" }), }) diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 5f36a32746..8684edf134 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" import { Session } from "@/session/session" @@ -126,12 +127,12 @@ describe("experimental HttpApi", () => { test("serves global session list through Hono bridge", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const first = await Instance.provide({ + const first = await WithInstance.provide({ directory: tmp.path, fn: async () => createSession({ title: "page-one" }), }) await new Promise((resolve) => setTimeout(resolve, 5)) - const second = await Instance.provide({ + const second = await WithInstance.provide({ directory: tmp.path, fn: async () => createSession({ title: "page-two" }), }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 7a889aea04..410dbe7426 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -10,8 +10,8 @@ import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceRuntime } from "../../src/project/instance-runtime" import { Instance } from "../../src/project/instance" +import { InstanceLayer } from "../../src/project/instance-layer" import { Project } from "../../src/project/project" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context" @@ -41,7 +41,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - InstanceRuntime.layer, + InstanceLayer.layer, Project.defaultLayer, Workspace.defaultLayer, ), diff --git a/packages/opencode/test/server/httpapi-mcp.test.ts b/packages/opencode/test/server/httpapi-mcp.test.ts index 396d04feb8..f442df5770 100644 --- a/packages/opencode/test/server/httpapi-mcp.test.ts +++ b/packages/opencode/test/server/httpapi-mcp.test.ts @@ -5,6 +5,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" @@ -59,7 +60,7 @@ function withMcpProject(self: (dir: string) => Effect.Effect) ) yield* Effect.addFinalizer(() => Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index 8118aa7842..c45a81838a 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -3,6 +3,7 @@ import { Effect, FileSystem, Layer, Path } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { InstanceRuntime } from "../../src/project/instance-runtime" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" @@ -91,7 +92,7 @@ function withProviderProject(self: (dir: string) => Effect.Effect Effect.promise(() => - Instance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), + WithInstance.provide({ directory: dir, fn: () => InstanceRuntime.disposeInstance(Instance.current) }), ).pipe(Effect.ignore), ) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 771fb57019..ce774ccfd0 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -5,6 +5,7 @@ import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Server } from "../../src/server/server" import { MessageID, PartID, SessionID } from "../../src/session/schema" @@ -226,7 +227,7 @@ function seedMessage(directory: string, sessionID: string) { const id = SessionID.make(sessionID) return call( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => Effect.runPromise( diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 02d590f918..70fe2d81b3 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -9,6 +9,7 @@ import { Workspace } from "../../src/control-plane/workspace" import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Project } from "../../src/project/project" import { Server } from "../../src/server/server" import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session" @@ -44,7 +45,7 @@ function pathFor(path: string, params: Record) { function createSession(directory: string, input?: Session.CreateInput) { return Effect.promise( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => runSession(Session.Service.use((svc) => svc.create(input))), }), @@ -54,7 +55,7 @@ function createSession(directory: string, input?: Session.CreateInput) { function createTextMessage(directory: string, sessionID: SessionID, text: string) { return Effect.promise( async () => - await Instance.provide({ + await WithInstance.provide({ directory, fn: () => runSession( diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index d022c37974..b85658ea1e 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" import { Session } from "@/session/session" @@ -38,7 +39,7 @@ describe("sync HttpApi", () => { const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } const info = spyOn(Log.create({ service: "server.sync" }), "info") - const session = await Instance.provide({ + const session = await WithInstance.provide({ directory: tmp.path, fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))), }) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 43f188e741..1ccc9bc8e6 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" @@ -31,7 +32,7 @@ afterEach(async () => { describe("session action routes", () => { test("abort route returns success", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 7d479a73b0..20478dde84 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import * as Log from "@opencode-ai/core/util/log" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -40,20 +41,20 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root" }) - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -73,20 +74,20 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root" }) - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -106,22 +107,22 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode", "src", "deep"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const parent = await Instance.provide({ + const parent = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode"), fn: async () => svc.create({ title: "parent" }), }) - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src"), fn: async () => svc.create({ title: "current" }), }) - const deeper = await Instance.provide({ + const deeper = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src", "deep"), fn: async () => svc.create({ title: "deeper" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "sibling" }), }) @@ -146,14 +147,14 @@ describe("session.list", () => { await mkdir(path.join(tmp.path, "packages", "opencode", "src"), { recursive: true }) await mkdir(path.join(tmp.path, "packages", "app"), { recursive: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { - const current = await Instance.provide({ + const current = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "opencode", "src"), fn: async () => svc.create({ title: "legacy-current" }), }) - const sibling = await Instance.provide({ + const sibling = await WithInstance.provide({ directory: path.join(tmp.path, "packages", "app"), fn: async () => svc.create({ title: "legacy-sibling" }), }) @@ -175,7 +176,7 @@ describe("session.list", () => { test("filters root sessions", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const root = await svc.create({ title: "root-session" }) @@ -192,7 +193,7 @@ describe("session.list", () => { test("filters by start time", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "new-session" }) @@ -206,7 +207,7 @@ describe("session.list", () => { test("filters by search term", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "unique-search-term-abc" }) @@ -223,7 +224,7 @@ describe("session.list", () => { test("respects limit parameter", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await svc.create({ title: "session-1" }) diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index e70847baf2..e3c5e83136 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { Effect } from "effect" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" @@ -76,7 +77,7 @@ describe("session messages endpoint", () => { test("returns cursor headers for older pages", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -105,7 +106,7 @@ describe("session messages endpoint", () => { test("keeps full-history responses when limit is omitted", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -126,7 +127,7 @@ describe("session messages endpoint", () => { test("rejects invalid cursors and missing sessions", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -147,7 +148,7 @@ describe("session messages endpoint", () => { test("does not truncate large legacy limit requests", async () => { await using tmp = await tmpdir({ git: true }) await withoutWatcher(() => - Instance.provide({ + WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index b3230d4b8a..13edca1458 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -4,6 +4,7 @@ import { Session as SessionNs } from "@/session/session" import type { SessionID } from "../../src/session/schema" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Server } from "../../src/server/server" import { disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -30,7 +31,7 @@ afterEach(async () => { describe("tui.selectSession endpoint", () => { test("should return 200 when called with valid session", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given @@ -56,7 +57,7 @@ describe("tui.selectSession endpoint", () => { test("should return 404 when session does not exist", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given @@ -78,7 +79,7 @@ describe("tui.selectSession endpoint", () => { test("should return 400 when session ID format is invalid", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // #given diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index f3f7cbaef7..df83adb8d4 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -10,6 +10,7 @@ import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" @@ -792,7 +793,7 @@ describe("session.compaction.prune", () => { describe("session.compaction.process", () => { test("throws when parent is not a user message", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -822,7 +823,7 @@ describe("session.compaction.process", () => { test("publishes compacted event on continue", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -872,7 +873,7 @@ describe("session.compaction.process", () => { test("marks summary message as errored on compact result", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -910,7 +911,7 @@ describe("session.compaction.process", () => { test("adds synthetic continue prompt when auto is enabled", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -951,7 +952,7 @@ describe("session.compaction.process", () => { test("persists tail_start_id for retained recent turns", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -998,7 +999,7 @@ describe("session.compaction.process", () => { test("shrinks retained tail to fit preserve token budget", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1047,7 +1048,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1096,7 +1097,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1155,7 +1156,7 @@ describe("session.compaction.process", () => { captured = JSON.stringify(input.messages) }), ) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1218,7 +1219,7 @@ describe("session.compaction.process", () => { test("allows plugins to disable synthetic continue prompt", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1261,7 +1262,7 @@ describe("session.compaction.process", () => { test("replays the prior user turn on overflow when earlier context exists", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1309,7 +1310,7 @@ describe("session.compaction.process", () => { test("falls back to overflow guidance when no replayable turn exists", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1369,7 +1370,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1443,7 +1444,7 @@ describe("session.compaction.process", () => { const ready = defer() await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1545,7 +1546,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1587,7 +1588,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1639,7 +1640,7 @@ describe("session.compaction.process", () => { ) await using tmp = await tmpdir({ git: true }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1707,7 +1708,7 @@ describe("session.compaction.process", () => { stub.push(reply("summary one")) stub.push(reply("summary two")) await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) @@ -1779,7 +1780,7 @@ describe("session.compaction.process", () => { test("ignores previous summaries when sizing the retained tail", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index c648d62be8..7b96084832 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -6,6 +6,7 @@ import z from "zod" import { makeRuntime } from "../../src/effect/run-service" import { LLM } from "../../src/session/llm" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@/provider/models" @@ -338,7 +339,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -425,7 +426,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -515,7 +516,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -629,7 +630,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) @@ -745,7 +746,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) @@ -864,7 +865,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) @@ -982,7 +983,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id)) @@ -1223,7 +1224,7 @@ describe("session.llm.stream", () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 17370bbe62..35b67f7a07 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect } from "effect" import path from "path" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" @@ -123,7 +124,7 @@ async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tai describe("MessageV2.page", () => { test("returns sync result", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -139,7 +140,7 @@ describe("MessageV2.page", () => { }) test("pages backward with opaque cursors", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -167,7 +168,7 @@ describe("MessageV2.page", () => { }) test("returns items in chronological order within a page", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -182,7 +183,7 @@ describe("MessageV2.page", () => { }) test("returns empty items for session with no messages", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -198,7 +199,7 @@ describe("MessageV2.page", () => { }) test("throws NotFoundError for non-existent session", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const fake = "non-existent-session" as SessionID @@ -208,7 +209,7 @@ describe("MessageV2.page", () => { }) test("handles exact limit boundary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -225,7 +226,7 @@ describe("MessageV2.page", () => { }) test("limit of 1 returns single newest message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -242,7 +243,7 @@ describe("MessageV2.page", () => { }) test("hydrates multiple parts per message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -266,7 +267,7 @@ describe("MessageV2.page", () => { }) test("accepts cursors from fractional timestamps", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -284,7 +285,7 @@ describe("MessageV2.page", () => { }) test("messages with same timestamp are ordered by id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -304,7 +305,7 @@ describe("MessageV2.page", () => { }) test("does not return messages from other sessions", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const a = await svc.create({}) @@ -326,7 +327,7 @@ describe("MessageV2.page", () => { }) test("large limit returns all messages without cursor", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -346,7 +347,7 @@ describe("MessageV2.page", () => { describe("MessageV2.stream", () => { test("yields items newest first", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -361,7 +362,7 @@ describe("MessageV2.stream", () => { }) test("yields nothing for empty session", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -375,7 +376,7 @@ describe("MessageV2.stream", () => { }) test("yields single message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -391,7 +392,7 @@ describe("MessageV2.stream", () => { }) test("hydrates parts for each yielded message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -409,7 +410,7 @@ describe("MessageV2.stream", () => { }) test("handles sets exceeding internal page size", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -426,7 +427,7 @@ describe("MessageV2.stream", () => { }) test("is a sync generator", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -447,7 +448,7 @@ describe("MessageV2.stream", () => { describe("MessageV2.parts", () => { test("returns parts for a message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -464,7 +465,7 @@ describe("MessageV2.parts", () => { }) test("returns empty array for message with no parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -479,7 +480,7 @@ describe("MessageV2.parts", () => { }) test("returns multiple parts in order", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -512,7 +513,7 @@ describe("MessageV2.parts", () => { }) test("returns empty for non-existent message id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { await svc.create({}) @@ -523,7 +524,7 @@ describe("MessageV2.parts", () => { }) test("parts contain sessionID and messageID", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -541,7 +542,7 @@ describe("MessageV2.parts", () => { describe("MessageV2.get", () => { test("returns message with hydrated parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -560,7 +561,7 @@ describe("MessageV2.get", () => { }) test("throws NotFoundError for non-existent message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -575,7 +576,7 @@ describe("MessageV2.get", () => { }) test("scopes by session id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const a = await svc.create({}) @@ -593,7 +594,7 @@ describe("MessageV2.get", () => { }) test("returns message with multiple parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -616,7 +617,7 @@ describe("MessageV2.get", () => { }) test("returns assistant message with correct role", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -642,7 +643,7 @@ describe("MessageV2.get", () => { }) test("returns message with zero parts", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -660,7 +661,7 @@ describe("MessageV2.get", () => { describe("MessageV2.filterCompacted", () => { test("returns all messages when no compaction", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -677,7 +678,7 @@ describe("MessageV2.filterCompacted", () => { }) test("stops at compaction boundary and returns chronological order", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -721,7 +722,7 @@ describe("MessageV2.filterCompacted", () => { }) test("does not break on compaction part without matching summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -739,7 +740,7 @@ describe("MessageV2.filterCompacted", () => { }) test("skips assistant with error even if marked as summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -764,7 +765,7 @@ describe("MessageV2.filterCompacted", () => { }) test("skips assistant without finish even if marked as summary", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -785,7 +786,7 @@ describe("MessageV2.filterCompacted", () => { }) test("retains original tail when compaction stores tail_start_id", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -841,7 +842,7 @@ describe("MessageV2.filterCompacted", () => { }) test("fork remaps compaction tail_start_id for filterCompacted", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -907,7 +908,7 @@ describe("MessageV2.filterCompacted", () => { }) test("retains an assistant tail when compaction starts inside a turn", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -971,7 +972,7 @@ describe("MessageV2.filterCompacted", () => { }) test("prefers latest compaction boundary when repeated compactions exist", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1093,7 +1094,7 @@ describe("MessageV2.cursor", () => { describe("MessageV2 consistency", () => { test("page hydration matches get for each message", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1112,7 +1113,7 @@ describe("MessageV2 consistency", () => { }) test("parts from get match standalone parts call", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1128,7 +1129,7 @@ describe("MessageV2 consistency", () => { }) test("stream collects same messages as exhaustive page iteration", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) @@ -1155,7 +1156,7 @@ describe("MessageV2 consistency", () => { }) test("filterCompacted of full stream returns same as Array.from when no compaction", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: root, fn: async () => { const session = await svc.create({}) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index 99f20b44dc..bb69e459bc 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -4,6 +4,7 @@ import { Session as SessionNs } from "@/session/session" import { Bus } from "../../src/bus" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" @@ -34,7 +35,7 @@ function updatePart(part: T) { describe("session.created event", () => { test("should emit session.created event when session is created", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { let eventReceived = false @@ -63,7 +64,7 @@ describe("session.created event", () => { }) test("session.created event should be emitted before session.updated", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const events: string[] = [] @@ -95,7 +96,7 @@ describe("step-finish token propagation via Bus event", () => { test( "non-zero tokens propagate through PartUpdated event", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const info = await create({}) @@ -166,7 +167,7 @@ describe("Session", () => { test("remove works without an instance", async () => { await using tmp = await tmpdir({ git: true }) - const info = await Instance.provide({ + const info = await WithInstance.provide({ directory: tmp.path, fn: () => create({ title: "remove-without-instance" }), }) diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index bdf95caed5..da2ffb7937 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -5,6 +5,7 @@ import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" import * as Log from "@opencode-ai/core/util/log" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { MessageV2 } from "../../src/session/message-v2" const projectRoot = path.join(__dirname, "../..") @@ -15,7 +16,7 @@ const hasApiKey = !!process.env.ANTHROPIC_API_KEY // Helper to run test within Instance context async function withInstance(fn: () => Promise): Promise { - return Instance.provide({ + return WithInstance.provide({ directory: projectRoot, fn, }) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index c3216e1c58..99ddfe72d4 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -5,6 +5,7 @@ import path from "path" import { Effect } from "effect" import { Snapshot } from "../../src/snapshot" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture" @@ -47,7 +48,7 @@ function run(dir: string, body: (snapshot: Snapshot.Interface) => Effect.Effe test("tracks deleted files correctly", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -62,7 +63,7 @@ test("tracks deleted files correctly", async () => { test("revert should remove new files", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -86,7 +87,7 @@ test("revert should remove new files", async () => { test("revert in subdirectory", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -113,7 +114,7 @@ test("revert in subdirectory", async () => { test("multiple file operations", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -145,7 +146,7 @@ test("multiple file operations", async () => { test("empty directory handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -160,7 +161,7 @@ test("empty directory handling", async () => { test("binary file handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -184,7 +185,7 @@ test("binary file handling", async () => { test("symlink handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -199,7 +200,7 @@ test("symlink handling", async () => { test("file under size limit handling", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -214,7 +215,7 @@ test("file under size limit handling", async () => { test("large added files are skipped", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -231,7 +232,7 @@ test("large added files are skipped", async () => { test("nested directory revert", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -256,7 +257,7 @@ test("nested directory revert", async () => { test("special characters in filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -276,7 +277,7 @@ test("special characters in filenames", async () => { test("revert with empty patches", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Should not crash with empty patches @@ -290,7 +291,7 @@ test("revert with empty patches", async () => { test("patch with invalid hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -309,7 +310,7 @@ test("patch with invalid hash", async () => { test("revert non-existent file", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -333,7 +334,7 @@ test("revert non-existent file", async () => { test("unicode filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -373,7 +374,7 @@ test("unicode filenames", async () => { test.skip("unicode filenames modification and restore", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const chineseFile = fwd(tmp.path, "文件.txt") @@ -402,7 +403,7 @@ test.skip("unicode filenames modification and restore", async () => { test("unicode filenames in subdirectories", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -428,7 +429,7 @@ test("unicode filenames in subdirectories", async () => { test("very long filenames", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -455,7 +456,7 @@ test("very long filenames", async () => { test("hidden files", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -475,7 +476,7 @@ test("hidden files", async () => { test("nested symlinks", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -495,7 +496,7 @@ test("nested symlinks", async () => { test("file permissions and ownership changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -516,7 +517,7 @@ test("file permissions and ownership changes", async () => { test("circular symlinks", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -547,7 +548,7 @@ test("source project gitignore is respected - ignored files are not snapshotted" }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -576,7 +577,7 @@ test("source project gitignore is respected - ignored files are not snapshotted" test("gitignore changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -600,7 +601,7 @@ test("gitignore changes", async () => { test("files tracked in snapshot but now gitignored are filtered out", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // First, create a file and snapshot it @@ -634,7 +635,7 @@ test("files tracked in snapshot but now gitignored are filtered out", async () = test("gitignore updated between track calls filters from diff", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // a.txt is already committed from bootstrap - track it in snapshot @@ -669,7 +670,7 @@ test("gitignore updated between track calls filters from diff", async () => { test("git info exclude changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -695,7 +696,7 @@ test("git info exclude changes", async () => { test("git info exclude keeps global excludes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const global = `${tmp.path}/global.ignore` @@ -731,7 +732,7 @@ test("git info exclude keeps global excludes", async () => { test("concurrent file operations during patch", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -763,7 +764,7 @@ test("snapshot state isolation between projects", async () => { await using tmp1 = await bootstrap() await using tmp2 = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp1.path, fn: async () => { const before1 = await run(tmp1.path, (snapshot) => snapshot.track()) @@ -773,7 +774,7 @@ test("snapshot state isolation between projects", async () => { }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp2.path, fn: async () => { const before2 = await run(tmp2.path, (snapshot) => snapshot.track()) @@ -793,14 +794,14 @@ test("patch detects changes in secondary worktree", async () => { await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() }, }) - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -825,7 +826,7 @@ test("revert only removes files in invoking worktree", async () => { await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() @@ -834,7 +835,7 @@ test("revert only removes files in invoking worktree", async () => { const primaryFile = `${tmp.path}/worktree.txt` await Filesystem.write(primaryFile, "primary content") - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -869,14 +870,14 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( await $`git worktree add ${worktreePath} HEAD`.cwd(tmp.path).quiet() try { - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { expect(await run(tmp.path, (snapshot) => snapshot.track())).toBeTruthy() }, }) - await Instance.provide({ + await WithInstance.provide({ directory: worktreePath, fn: async () => { const before = await run(worktreePath, (snapshot) => snapshot.track()) @@ -903,7 +904,7 @@ test("diff reports worktree-only/shared edits and ignores primary-only", async ( test("track with no changes returns same hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const hash1 = await run(tmp.path, (snapshot) => snapshot.track()) @@ -922,7 +923,7 @@ test("track with no changes returns same hash", async () => { test("diff function with various changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -943,7 +944,7 @@ test("diff function with various changes", async () => { test("restore function", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -977,7 +978,7 @@ test("restore function", async () => { test("revert should not delete files that existed but were deleted in snapshot", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const snapshot1 = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1007,7 +1008,7 @@ test("revert should not delete files that existed but were deleted in snapshot", test("revert preserves file that existed in snapshot when deleted then recreated", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/existing.txt`, "original content") @@ -1044,7 +1045,7 @@ test("revert preserves file that existed in snapshot when deleted then recreated test("diffFull sets status based on git change type", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/grow.txt`, "one\n") @@ -1090,7 +1091,7 @@ test("diffFull sets status based on git change type", async () => { test("diffFull with new file additions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1115,7 +1116,7 @@ test("diffFull with new file additions", async () => { test("diffFull with a large interleaved mixed diff", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ids = Array.from({ length: 60 }, (_, i) => i.toString().padStart(3, "0")) @@ -1178,7 +1179,7 @@ test("diffFull with a large interleaved mixed diff", async () => { test("diffFull preserves git diff order across batch boundaries", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const ids = Array.from({ length: 140 }, (_, i) => i.toString().padStart(3, "0")) @@ -1204,7 +1205,7 @@ test("diffFull preserves git diff order across batch boundaries", async () => { test("diffFull with file modifications", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1230,7 +1231,7 @@ test("diffFull with file modifications", async () => { test("diffFull with file deletions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1255,7 +1256,7 @@ test("diffFull with file deletions", async () => { test("diffFull with multiple line additions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1281,7 +1282,7 @@ test("diffFull with multiple line additions", async () => { test("diffFull with addition and deletion", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1313,7 +1314,7 @@ test("diffFull with addition and deletion", async () => { test("diffFull with multiple additions and deletions", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1355,7 +1356,7 @@ test("diffFull with multiple additions and deletions", async () => { test("diffFull with no changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1372,7 +1373,7 @@ test("diffFull with no changes", async () => { test("diffFull with binary file changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const before = await run(tmp.path, (snapshot) => snapshot.track()) @@ -1395,7 +1396,7 @@ test("diffFull with binary file changes", async () => { test("diffFull with whitespace changes", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await Filesystem.write(`${tmp.path}/whitespace.txt`, "line1\nline2") @@ -1419,7 +1420,7 @@ test("diffFull with whitespace changes", async () => { test("revert with overlapping files across patches uses first patch hash", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { // Write initial content and snapshot @@ -1453,7 +1454,7 @@ test("revert with overlapping files across patches uses first patch hash", async test("revert preserves patch order when the same hash appears again", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await $`mkdir -p ${tmp.path}/foo`.quiet() @@ -1490,7 +1491,7 @@ test("revert preserves patch order when the same hash appears again", async () = test("revert handles large mixed batches across chunk boundaries", async () => { await using tmp = await bootstrap() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const base = Array.from({ length: 140 }, (_, i) => fwd(tmp.path, "batch", `${i}.txt`)) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index c4cccc6eb5..fd24b557b3 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -4,6 +4,7 @@ import * as fs from "fs/promises" import { Effect, ManagedRuntime, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Format } from "../../src/format" @@ -97,7 +98,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const modifyPath = path.join(fixture.path, "modify.txt") @@ -149,7 +150,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir({ git: true }) const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -179,7 +180,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "multi.txt") @@ -199,7 +200,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx, calls } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const bom = String.fromCharCode(0xfeff) @@ -228,7 +229,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "insert_only.txt") @@ -247,7 +248,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "no_newline.txt") @@ -269,7 +270,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -292,7 +293,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const original = path.join(fixture.path, "old", "name.txt") @@ -317,7 +318,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "duplicate.txt") @@ -335,7 +336,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" @@ -351,7 +352,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" @@ -365,7 +366,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const dirPath = path.join(fixture.path, "dir") @@ -382,7 +383,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" @@ -396,7 +397,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "modify.txt") @@ -414,7 +415,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = @@ -432,7 +433,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "tail.txt") @@ -450,7 +451,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "two_chunks.txt") @@ -468,7 +469,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "multi_ctx.txt") @@ -486,7 +487,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "eof_anchor.txt") @@ -508,7 +509,7 @@ describe("tool.apply_patch freeform", () => { await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = `cat <<'EOF' @@ -529,7 +530,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const patchText = `< { const target = path.join(fixture.path, "trailing_ws.txt") @@ -570,7 +571,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "leading_ws.txt") @@ -590,7 +591,7 @@ EOF` await using fixture = await tmpdir() const { ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: fixture.path, fn: async () => { const target = path.join(fixture.path, "unicode.txt") diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 2c381ad047..23ae0e9090 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { Effect, Layer, ManagedRuntime } from "effect" import { EditTool } from "../../src/tool/edit" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { LSP } from "@/lsp/lsp" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -73,7 +74,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "newfile.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -102,7 +103,7 @@ describe("tool.edit", () => { const bom = String.fromCharCode(0xfeff) await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -131,7 +132,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nested", "dir", "file.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -156,7 +157,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "new.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { FileWatcher } = await import("../../src/file/watcher") @@ -191,7 +192,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "existing.txt") await fs.writeFile(filepath, "old content here", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -220,7 +221,7 @@ describe("tool.edit", () => { const bom = String.fromCharCode(0xfeff) await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -250,7 +251,7 @@ describe("tool.edit", () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nonexistent.txt") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -275,7 +276,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -300,7 +301,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "actual content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -325,7 +326,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -352,7 +353,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "original", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const { FileWatcher } = await import("../../src/file/watcher") @@ -387,7 +388,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -413,7 +414,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -439,7 +440,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "content", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -464,7 +465,7 @@ describe("tool.edit", () => { const dirpath = path.join(tmp.path, "adir") await fs.mkdir(dirpath) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -489,7 +490,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -558,7 +559,7 @@ describe("tool.edit", () => { }, }) - return await Instance.provide({ + return await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() @@ -702,7 +703,7 @@ describe("tool.edit", () => { const filepath = path.join(tmp.path, "file.txt") await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index ea1d340ce8..5914918178 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -3,6 +3,7 @@ import path from "path" import { Effect } from "effect" import type { Tool } from "@/tool/tool" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { assertExternalDirectory } from "../../src/tool/external-directory" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -38,7 +39,7 @@ describe("tool.assertExternalDirectory", () => { test("no-ops for empty target", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp", fn: async () => { await assertExternalDirectory(ctx) @@ -51,7 +52,7 @@ describe("tool.assertExternalDirectory", () => { test("no-ops for paths inside Instance.directory", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp/project", fn: async () => { await assertExternalDirectory(ctx, path.join("/tmp/project", "file.txt")) @@ -68,7 +69,7 @@ describe("tool.assertExternalDirectory", () => { const target = "/tmp/outside/file.txt" const expected = glob(path.join(path.dirname(target), "*")) - await Instance.provide({ + await WithInstance.provide({ directory, fn: async () => { await assertExternalDirectory(ctx, target) @@ -88,7 +89,7 @@ describe("tool.assertExternalDirectory", () => { const target = "/tmp/outside" const expected = glob(path.join(target, "*")) - await Instance.provide({ + await WithInstance.provide({ directory, fn: async () => { await assertExternalDirectory(ctx, target, { kind: "directory" }) @@ -104,7 +105,7 @@ describe("tool.assertExternalDirectory", () => { test("skips prompting when bypass=true", async () => { const { requests, ctx } = makeCtx() - await Instance.provide({ + await WithInstance.provide({ directory: "/tmp/project", fn: async () => { await assertExternalDirectory(ctx, "/tmp/outside/file.txt", { bypass: true }) @@ -131,7 +132,7 @@ describe("tool.assertExternalDirectory", () => { .replaceAll("\\", "/") .toLowerCase() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await assertExternalDirectory(ctx, alt) @@ -152,7 +153,7 @@ describe("tool.assertExternalDirectory", () => { const root = path.parse(tmp.path).root const target = path.join(root, "boot.ini") - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { await assertExternalDirectory(ctx, target) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index e68d16ba81..9b5c17c222 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -6,6 +6,7 @@ import { Config } from "@/config/config" import { Shell } from "../../src/shell/shell" import { ShellTool } from "../../src/tool/shell" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { Permission } from "../../src/permission" @@ -140,7 +141,7 @@ const mustTruncate = (result: { describe("tool.shell", () => { each("basic", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -163,7 +164,7 @@ describe("tool.shell", () => { await using tmp = await tmpdir({ config: { shell: "fish" }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -190,7 +191,7 @@ describe("tool.shell", () => { describe("tool.shell permissions", () => { each("asks for bash permission with correct pattern", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -213,7 +214,7 @@ describe("tool.shell permissions", () => { each("asks for bash permission with multiple commands", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -239,7 +240,7 @@ describe("tool.shell permissions", () => { test( `parses PowerShell conditionals for permission prompts [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -269,7 +270,7 @@ describe("tool.shell permissions", () => { `uses PowerShell cmdlet prefixes for always-allow prompts [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -297,7 +298,7 @@ describe("tool.shell permissions", () => { } each("asks for external_directory permission for wildcard external paths", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -333,7 +334,7 @@ describe("tool.shell permissions", () => { await Bun.write(path.join(dir, "outside.txt"), "x") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -366,7 +367,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for PowerShell paths after switches [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -396,7 +397,7 @@ describe("tool.shell permissions", () => { test( `asks for nested PowerShell command permissions [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -428,7 +429,7 @@ describe("tool.shell permissions", () => { `asks for external_directory permission for drive-relative PowerShell paths [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -458,7 +459,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for $HOME PowerShell paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -489,7 +490,7 @@ describe("tool.shell permissions", () => { `asks for external_directory permission for $PWD PowerShell paths [${item.label}]`, withShell(item, async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -519,7 +520,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for $PSHOME PowerShell paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -553,7 +554,7 @@ describe("tool.shell permissions", () => { const prev = process.env[key] delete process.env[key] try { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -588,7 +589,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for PowerShell env paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -617,7 +618,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for PowerShell FileSystem paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -649,7 +650,7 @@ describe("tool.shell permissions", () => { test( `asks for external_directory permission for braced PowerShell env paths [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -681,7 +682,7 @@ describe("tool.shell permissions", () => { test( `treats Set-Location like cd for permissions [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -712,7 +713,7 @@ describe("tool.shell permissions", () => { test( `does not add nested PowerShell expressions to permission prompts [${item.label}]`, withShell(item, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -741,7 +742,7 @@ describe("tool.shell permissions", () => { test( "asks for external_directory permission for cmd file commands [cmd]", withShell(cmdShell, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -766,7 +767,7 @@ describe("tool.shell permissions", () => { each("asks for external_directory permission when cd to parent", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -791,7 +792,7 @@ describe("tool.shell permissions", () => { each("asks for external_directory permission when workdir is outside project", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -821,7 +822,7 @@ describe("tool.shell permissions", () => { const err = new Error("stop after permission") await using outerTmp = await tmpdir() await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -857,7 +858,7 @@ describe("tool.shell permissions", () => { test( "uses Git Bash /tmp semantics for external workdir", withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -889,7 +890,7 @@ describe("tool.shell permissions", () => { test( "uses Git Bash /tmp semantics for external file paths", withShell({ label: "bash", shell: bash }, async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -926,7 +927,7 @@ describe("tool.shell permissions", () => { }, }) await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -959,7 +960,7 @@ describe("tool.shell permissions", () => { await Bun.write(path.join(dir, "tmpfile"), "x") }, }) - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -981,7 +982,7 @@ describe("tool.shell permissions", () => { each("includes always patterns for auto-approval", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -1004,7 +1005,7 @@ describe("tool.shell permissions", () => { each("does not ask for bash permission when command is cd only", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -1026,7 +1027,7 @@ describe("tool.shell permissions", () => { each("matches redirects in permission pattern", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initShell() @@ -1049,7 +1050,7 @@ describe("tool.shell permissions", () => { each("always pattern has space before wildcard to not include different commands", async () => { await using tmp = await tmpdir() - await Instance.provide({ + await WithInstance.provide({ directory: tmp.path, fn: async () => { const bash = await initBash() @@ -1065,7 +1066,7 @@ describe("tool.shell permissions", () => { describe("tool.shell abort", () => { test("preserves output when aborted", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1099,7 +1100,7 @@ describe("tool.shell abort", () => { }, 15_000) test("terminates command on timeout", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1121,7 +1122,7 @@ describe("tool.shell abort", () => { }, 15_000) test.skipIf(process.platform === "win32")("captures stderr in output", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1142,7 +1143,7 @@ describe("tool.shell abort", () => { }) test("returns non-zero exit code", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1161,7 +1162,7 @@ describe("tool.shell abort", () => { }) test("streams metadata updates progressively", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initBash() @@ -1192,7 +1193,7 @@ describe("tool.shell abort", () => { describe("tool.shell truncation", () => { test("truncates output exceeding line limit", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1214,7 +1215,7 @@ describe("tool.shell truncation", () => { }) test("truncates output exceeding byte limit", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1236,7 +1237,7 @@ describe("tool.shell truncation", () => { }) test("does not truncate small output", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() @@ -1256,7 +1257,7 @@ describe("tool.shell truncation", () => { }) test("full output is saved to file when truncated", async () => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const bash = await initShell() diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index dbde4ed5b7..6c7f6aba77 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -5,6 +5,7 @@ import { FetchHttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" import { Instance } from "../../src/project/instance" +import { WithInstance } from "../../src/project/with-instance" import { WebFetchTool } from "../../src/tool/webfetch" import { SessionID, MessageID } from "../../src/session/schema" @@ -41,7 +42,7 @@ describe("tool.webfetch", () => { await withFetch( () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" }) @@ -69,7 +70,7 @@ describe("tool.webfetch", () => { headers: { "content-type": "image/svg+xml; charset=UTF-8" }, }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" }) @@ -89,7 +90,7 @@ describe("tool.webfetch", () => { headers: { "content-type": "text/plain; charset=utf-8" }, }), async (url) => { - await Instance.provide({ + await WithInstance.provide({ directory: projectRoot, fn: async () => { const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" }) From 80f2b13a55035517860cc85d45b00634b5a4c7cd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 00:40:21 +0000 Subject: [PATCH 064/178] chore: generate --- .../opencode/src/project/with-instance.ts | 4 +- .../opencode/test/project/instance.test.ts | 1 - packages/sdk/js/src/v2/gen/types.gen.ts | 176 +++---- packages/sdk/openapi.json | 484 +++++++++--------- 4 files changed, 333 insertions(+), 332 deletions(-) diff --git a/packages/opencode/src/project/with-instance.ts b/packages/opencode/src/project/with-instance.ts index b5b0e7c079..b7b5360c75 100644 --- a/packages/opencode/src/project/with-instance.ts +++ b/packages/opencode/src/project/with-instance.ts @@ -3,7 +3,9 @@ import { context } from "./instance-context" import { InstanceStore } from "./instance-store" export async function provide(input: { directory: string; fn: () => R }): Promise { - const ctx = await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.load({ directory: input.directory }))) + const ctx = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory: input.directory })), + ) return context.provide(ctx, () => input.fn()) } diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 655e381b9a..99b0f0666b 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -236,5 +236,4 @@ describe("InstanceStore", () => { expect(() => Instance.current).toThrow() }), ) - }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index af29de17f2..31bd40ab4f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4,39 +4,25 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type Project = { - id: string - worktree: string - vcs?: "git" - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - time: { - created: number - updated: number - initialized?: number +export type EventServerInstanceDisposed = { + type: "server.instance.disposed" + properties: { + directory: string } - sandboxes: Array } -export type EventProjectUpdated = { - type: "project.updated" - properties: Project +export type EventFileEdited = { + type: "file.edited" + properties: { + file: string + } } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" properties: { - directory: string + file: string + event: "add" | "change" | "unlink" } } @@ -201,53 +187,6 @@ export type EventInstallationUpdateAvailable = { } } -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} - -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -463,6 +402,35 @@ export type EventCommandExecuted = { } } +export type Project = { + id: string + worktree: string + vcs?: "git" + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } + time: { + created: number + updated: number + initialized?: number + } + sandboxes: Array +} + +export type EventProjectUpdated = { + type: "project.updated" + properties: Project +} + export type EventVcsBranchUpdated = { type: "vcs.branch.updated" properties: { @@ -470,6 +438,38 @@ export type EventVcsBranchUpdated = { } } +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + export type EventWorktreeReady = { type: "worktree.ready" properties: { @@ -1111,8 +1111,9 @@ export type GlobalEvent = { project?: string workspace?: string payload: - | EventProjectUpdated | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -1122,12 +1123,6 @@ export type GlobalEvent = { | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventFileEdited - | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -1142,7 +1137,12 @@ export type GlobalEvent = { | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted + | EventProjectUpdated | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated @@ -2060,8 +2060,9 @@ export type File = { } export type Event = - | EventProjectUpdated | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated | EventLspClientDiagnostics | EventLspUpdated | EventMessagePartDelta @@ -2071,12 +2072,6 @@ export type Event = | EventSessionError | EventInstallationUpdated | EventInstallationUpdateAvailable - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventFileEdited - | EventFileWatcherUpdated | EventQuestionAsked | EventQuestionReplied | EventQuestionRejected @@ -2091,7 +2086,12 @@ export type Event = | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted + | EventProjectUpdated | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed | EventPtyCreated diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 680771e18b..208346325b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7592,103 +7592,63 @@ }, "components": { "schemas": { - "Project": { + "Event.server.instance.disposed": { "type": "object", "properties": { - "id": { - "type": "string" - }, - "worktree": { - "type": "string" - }, - "vcs": { + "type": { "type": "string", - "const": "git" - }, - "name": { - "type": "string" - }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - } + "const": "server.instance.disposed" }, - "commands": { + "properties": { "type": "object", "properties": { - "start": { - "description": "Startup script to run when creating a new workspace (worktree)", + "directory": { "type": "string" } - } - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "updated": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "initialized": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } }, - "required": ["created", "updated"] - }, - "sandboxes": { - "type": "array", - "items": { - "type": "string" - } + "required": ["directory"] } }, - "required": ["id", "worktree", "time", "sandboxes"] + "required": ["type", "properties"] }, - "Event.project.updated": { + "Event.file.edited": { "type": "object", "properties": { "type": { "type": "string", - "const": "project.updated" + "const": "file.edited" }, "properties": { - "$ref": "#/components/schemas/Project" + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"] } }, "required": ["type", "properties"] }, - "Event.server.instance.disposed": { + "Event.file.watcher.updated": { "type": "object", "properties": { "type": { "type": "string", - "const": "server.instance.disposed" + "const": "file.watcher.updated" }, "properties": { "type": "object", "properties": { - "directory": { + "file": { "type": "string" + }, + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] } }, - "required": ["directory"] + "required": ["file", "event"] } }, "required": ["type", "properties"] @@ -8155,144 +8115,6 @@ }, "required": ["type", "properties"] }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.edited": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.edited" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] - } - }, - "required": ["type", "properties"] - }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "QuestionOption": { "type": "object", "properties": { @@ -8793,6 +8615,88 @@ }, "required": ["type", "properties"] }, + "Project": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "worktree": { + "type": "string" + }, + "vcs": { + "type": "string", + "const": "git" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + } + }, + "commands": { + "type": "object", + "properties": { + "start": { + "description": "Startup script to run when creating a new workspace (worktree)", + "type": "string" + } + } + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "updated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "initialized": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["created", "updated"] + }, + "sandboxes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["id", "worktree", "time", "sandboxes"] + }, + "Event.project.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "project.updated" + }, + "properties": { + "$ref": "#/components/schemas/Project" + } + }, + "required": ["type", "properties"] + }, "Event.vcs.branch.updated": { "type": "object", "properties": { @@ -8811,6 +8715,102 @@ }, "required": ["type", "properties"] }, + "Event.workspace.ready": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.ready" + }, + "properties": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.failed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.failed" + }, + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, + "Event.workspace.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.status" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"] + } + }, + "required": ["type", "properties"] + }, "Event.worktree.ready": { "type": "object", "properties": { @@ -10958,10 +10958,13 @@ "payload": { "anyOf": [ { - "$ref": "#/components/schemas/Event.project.updated" + "$ref": "#/components/schemas/Event.server.instance.disposed" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -10990,24 +10993,6 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -11050,9 +11035,24 @@ { "$ref": "#/components/schemas/Event.command.executed" }, + { + "$ref": "#/components/schemas/Event.project.updated" + }, { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, { "$ref": "#/components/schemas/Event.worktree.ready" }, @@ -13253,10 +13253,13 @@ "Event": { "anyOf": [ { - "$ref": "#/components/schemas/Event.project.updated" + "$ref": "#/components/schemas/Event.server.instance.disposed" }, { - "$ref": "#/components/schemas/Event.server.instance.disposed" + "$ref": "#/components/schemas/Event.file.edited" + }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" @@ -13285,24 +13288,6 @@ { "$ref": "#/components/schemas/Event.installation.update-available" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.question.asked" }, @@ -13345,9 +13330,24 @@ { "$ref": "#/components/schemas/Event.command.executed" }, + { + "$ref": "#/components/schemas/Event.project.updated" + }, { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.workspace.ready" + }, + { + "$ref": "#/components/schemas/Event.workspace.failed" + }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, + { + "$ref": "#/components/schemas/Event.workspace.status" + }, { "$ref": "#/components/schemas/Event.worktree.ready" }, From 68b3448b09fd72858d5ca7f01ade0dd29fa87adf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:42:09 -0400 Subject: [PATCH 065/178] refactor(cli): drop redundant explicit Effect.ensuring(store.dispose) (#25503) --- packages/opencode/src/cli/cmd/debug/agent.ts | 4 +- packages/opencode/src/cli/cmd/debug/config.ts | 11 +-- packages/opencode/src/cli/cmd/debug/file.ts | 47 +++-------- packages/opencode/src/cli/cmd/debug/lsp.ts | 43 ++++------ .../opencode/src/cli/cmd/debug/ripgrep.ts | 66 +++++++--------- packages/opencode/src/cli/cmd/debug/skill.ts | 13 +--- .../opencode/src/cli/cmd/debug/snapshot.ts | 29 ++----- packages/opencode/src/cli/cmd/export.ts | 7 +- packages/opencode/src/cli/cmd/import.ts | 7 +- packages/opencode/src/cli/cmd/session.ts | 78 ++++++++----------- packages/opencode/src/cli/cmd/stats.ts | 4 +- 11 files changed, 99 insertions(+), 210 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index 831ca08b69..1a3f79396c 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -11,7 +11,6 @@ import { Permission } from "../../../permission" import { iife } from "../../../util/iife" import { effectCmd, fail } from "../../effect-cmd" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" import type { InstanceContext } from "@/project/instance" export const AgentCommand = effectCmd({ @@ -35,8 +34,7 @@ export const AgentCommand = effectCmd({ handler: Effect.fn("Cli.debug.agent")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* run(args, ctx).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* run(args, ctx) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/config.ts b/packages/opencode/src/cli/cmd/debug/config.ts index 8102fcfb88..15bd1c1a92 100644 --- a/packages/opencode/src/cli/cmd/debug/config.ts +++ b/packages/opencode/src/cli/cmd/debug/config.ts @@ -2,20 +2,13 @@ import { EOL } from "os" import { Effect } from "effect" import { Config } from "@/config/config" import { effectCmd } from "../../effect-cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const ConfigCommand = effectCmd({ command: "config", describe: "show resolved configuration", builder: (yargs) => yargs, handler: Effect.fn("Cli.debug.config")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const config = yield* Config.Service.use((cfg) => cfg.get()) - process.stdout.write(JSON.stringify(config, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const config = yield* Config.Service.use((cfg) => cfg.get()) + process.stdout.write(JSON.stringify(config, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 1e2eb13bb7..d9bb252ea9 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -4,8 +4,6 @@ import { File } from "../../../file" import { Ripgrep } from "@/file/ripgrep" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" const FileSearchCommand = effectCmd({ command: "search ", @@ -17,13 +15,8 @@ const FileSearchCommand = effectCmd({ description: "Search query", }), handler: Effect.fn("Cli.debug.file.search")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const results = yield* File.Service.use((svc) => svc.search({ query: args.query })) - process.stdout.write(results.join(EOL) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const results = yield* File.Service.use((svc) => svc.search({ query: args.query })) + process.stdout.write(results.join(EOL) + EOL) }), }) @@ -37,13 +30,8 @@ const FileReadCommand = effectCmd({ description: "File path to read", }), handler: Effect.fn("Cli.debug.file.read")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const content = yield* File.Service.use((svc) => svc.read(args.path)) - process.stdout.write(JSON.stringify(content, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const content = yield* File.Service.use((svc) => svc.read(args.path)) + process.stdout.write(JSON.stringify(content, null, 2) + EOL) }), }) @@ -52,13 +40,8 @@ const FileStatusCommand = effectCmd({ describe: "show file status information", builder: (yargs) => yargs, handler: Effect.fn("Cli.debug.file.status")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const status = yield* File.Service.use((svc) => svc.status()) - process.stdout.write(JSON.stringify(status, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const status = yield* File.Service.use((svc) => svc.status()) + process.stdout.write(JSON.stringify(status, null, 2) + EOL) }), }) @@ -72,13 +55,8 @@ const FileListCommand = effectCmd({ description: "File path to list", }), handler: Effect.fn("Cli.debug.file.list")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const files = yield* File.Service.use((svc) => svc.list(args.path)) - process.stdout.write(JSON.stringify(files, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const files = yield* File.Service.use((svc) => svc.list(args.path)) + process.stdout.write(JSON.stringify(files, null, 2) + EOL) }), }) @@ -92,13 +70,8 @@ const FileTreeCommand = effectCmd({ default: process.cwd(), }), handler: Effect.fn("Cli.debug.file.tree")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) - console.log(JSON.stringify(tree, null, 2)) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: args.dir, limit: 200 }))) + console.log(JSON.stringify(tree, null, 2)) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index b822a98bc1..b40b423181 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -4,8 +4,6 @@ import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import * as Log from "@opencode-ai/core/util/log" import { EOL } from "os" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const LSPCommand = cmd({ command: "lsp", @@ -20,18 +18,13 @@ const DiagnosticsCommand = effectCmd({ describe: "get diagnostics for a file", builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }), handler: Effect.fn("Cli.debug.lsp.diagnostics")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* LSP.Service.use((lsp) => - Effect.gen(function* () { - yield* lsp.touchFile(args.file, "full") - return yield* lsp.diagnostics() - }), - ) - process.stdout.write(JSON.stringify(out, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* LSP.Service.use((lsp) => + Effect.gen(function* () { + yield* lsp.touchFile(args.file, "full") + return yield* lsp.diagnostics() + }), + ) + process.stdout.write(JSON.stringify(out, null, 2) + EOL) }), }) @@ -40,14 +33,9 @@ export const SymbolsCommand = effectCmd({ describe: "search workspace symbols", builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }), handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - using _ = Log.Default.time("symbols") - const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + using _ = Log.Default.time("symbols") + const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)) + process.stdout.write(JSON.stringify(results, null, 2) + EOL) }), }) @@ -56,13 +44,8 @@ export const DocumentSymbolsCommand = effectCmd({ describe: "get symbols from a document", builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }), handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - using _ = Log.Default.time("document-symbols") - const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) - process.stdout.write(JSON.stringify(results, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + using _ = Log.Default.time("document-symbols") + const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)) + process.stdout.write(JSON.stringify(results, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index f0be704485..ca95c1d559 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -4,7 +4,6 @@ import { Ripgrep } from "../../../file/ripgrep" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const RipgrepCommand = cmd({ command: "rg", @@ -23,13 +22,10 @@ const TreeCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const tree = yield* Effect.orDie( - Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })), - ) - process.stdout.write(tree + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const tree = yield* Effect.orDie( + Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })), + ) + process.stdout.write(tree + EOL) }), }) @@ -53,22 +49,19 @@ const FilesCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.files")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const rg = yield* Ripgrep.Service - const files = yield* rg - .files({ - cwd: ctx.directory, - glob: args.glob ? [args.glob] : undefined, - }) - .pipe( - Stream.take(args.limit ?? Infinity), - Stream.runCollect, - Effect.map((c) => [...c]), - Effect.orDie, - ) - process.stdout.write(files.join(EOL) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const rg = yield* Ripgrep.Service + const files = yield* rg + .files({ + cwd: ctx.directory, + glob: args.glob ? [args.glob] : undefined, + }) + .pipe( + Stream.take(args.limit ?? Infinity), + Stream.runCollect, + Effect.map((c) => [...c]), + Effect.orDie, + ) + process.stdout.write(files.join(EOL) + EOL) }), }) @@ -93,19 +86,16 @@ const SearchCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.search")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const results = yield* Effect.orDie( - Ripgrep.Service.use((svc) => - svc.search({ - cwd: ctx.directory, - pattern: args.pattern, - glob: args.glob as string[] | undefined, - limit: args.limit, - }), - ), - ) - process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const results = yield* Effect.orDie( + Ripgrep.Service.use((svc) => + svc.search({ + cwd: ctx.directory, + pattern: args.pattern, + glob: args.glob as string[] | undefined, + limit: args.limit, + }), + ), + ) + process.stdout.write(JSON.stringify(results.items, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/skill.ts b/packages/opencode/src/cli/cmd/debug/skill.ts index e23410a69b..3b120da3cb 100644 --- a/packages/opencode/src/cli/cmd/debug/skill.ts +++ b/packages/opencode/src/cli/cmd/debug/skill.ts @@ -2,21 +2,14 @@ import { EOL } from "os" import { Effect } from "effect" import { Skill } from "../../../skill" import { effectCmd } from "../../effect-cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const SkillCommand = effectCmd({ command: "skill", describe: "list all available skills", builder: (yargs) => yargs, handler: Effect.fn("Cli.debug.skill")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const skill = yield* Skill.Service - const skills = yield* skill.all() - process.stdout.write(JSON.stringify(skills, null, 2) + EOL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const skill = yield* Skill.Service + const skills = yield* skill.all() + process.stdout.write(JSON.stringify(skills, null, 2) + EOL) }), }) diff --git a/packages/opencode/src/cli/cmd/debug/snapshot.ts b/packages/opencode/src/cli/cmd/debug/snapshot.ts index 1675f175df..e37e63dc47 100644 --- a/packages/opencode/src/cli/cmd/debug/snapshot.ts +++ b/packages/opencode/src/cli/cmd/debug/snapshot.ts @@ -2,8 +2,6 @@ import { Effect } from "effect" import { Snapshot } from "../../../snapshot" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" export const SnapshotCommand = cmd({ command: "snapshot", @@ -16,13 +14,8 @@ const TrackCommand = effectCmd({ command: "track", describe: "track current snapshot state", handler: Effect.fn("Cli.debug.snapshot.track")(function* () { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* Snapshot.Service.use((svc) => svc.track()) - console.log(out) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* Snapshot.Service.use((svc) => svc.track()) + console.log(out) }), }) @@ -36,13 +29,8 @@ const PatchCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.debug.snapshot.patch")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash)) - console.log(out) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* Snapshot.Service.use((svc) => svc.patch(args.hash)) + console.log(out) }), }) @@ -56,12 +44,7 @@ const DiffCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.debug.snapshot.diff")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash)) - console.log(out) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const out = yield* Snapshot.Service.use((svc) => svc.diff(args.hash)) + console.log(out) }), }) diff --git a/packages/opencode/src/cli/cmd/export.ts b/packages/opencode/src/cli/cmd/export.ts index 5ff282b543..bf73ce941e 100644 --- a/packages/opencode/src/cli/cmd/export.ts +++ b/packages/opencode/src/cli/cmd/export.ts @@ -6,8 +6,6 @@ import { UI } from "../ui" import * as prompts from "@clack/prompts" import { EOL } from "os" import { Effect } from "effect" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" function redact(kind: string, id: string, value: string) { return value.trim() ? `[redacted:${kind}:${id}]` : value @@ -234,10 +232,7 @@ export const ExportCommand = effectCmd({ type: "boolean", }), handler: Effect.fn("Cli.export")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* run(args).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* run(args) }), }) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 8d19376662..419e81379b 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -5,7 +5,6 @@ import { CliError, effectCmd } from "../effect-cmd" import { Database } from "@/storage/db" import { SessionTable, MessageTable, PartTable } from "../../session/session.sql" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" import { ShareNext } from "@/share/share-next" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" @@ -88,13 +87,9 @@ export const ImportCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.import")(function* (args) { - // effectCmd always provides InstanceRef via InstanceStore.Service.provide; this is an invariant. const ctx = yield* InstanceRef if (!ctx) return yield* Effect.die("InstanceRef not provided") - const store = yield* InstanceStore.Service - // Ensure store.dispose runs disposers and emits server.instance.disposed - // on every exit path: success, early return, typed failure, defect, interrupt. - return yield* runImport(args.file, ctx.project.id).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* runImport(args.file, ctx.project.id) }), }) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index dbf27ccc6c..08c0df929c 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -12,8 +12,6 @@ import { Process } from "@/util/process" import { EOL } from "os" import path from "path" import { which } from "../../util/which" -import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" function pagerCmd(): string[] { const lessOptions = ["-R", "-S"] @@ -59,17 +57,12 @@ export const SessionDeleteCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.session.delete")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const svc = yield* Session.Service - const sessionID = SessionID.make(args.sessionID) - // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. - yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) - yield* svc.remove(sessionID) - UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) - }).pipe(Effect.ensuring(store.dispose(ctx))) + const svc = yield* Session.Service + const sessionID = SessionID.make(args.sessionID) + // Match legacy try/catch — Session.get surfaces NotFoundError as a defect. + yield* svc.get(sessionID).pipe(Effect.catchCause(() => fail(`Session not found: ${args.sessionID}`))) + yield* svc.remove(sessionID) + UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL) }), }) @@ -90,39 +83,34 @@ export const SessionListCommand = effectCmd({ default: "table", }), handler: Effect.fn("Cli.session.list")(function* (args) { - const ctx = yield* InstanceRef - if (!ctx) return - const store = yield* InstanceStore.Service - return yield* Effect.gen(function* () { - const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })) - - if (sessions.length === 0) return - - const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions) - - const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" - - if (shouldPaginate) { - yield* Effect.promise(async () => { - const proc = Process.spawn(pagerCmd(), { - stdin: "pipe", - stdout: "inherit", - stderr: "inherit", - }) - - if (!proc.stdin) { - console.log(output) - return - } - - proc.stdin.write(output) - proc.stdin.end() - await proc.exited + const sessions = yield* Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount })) + + if (sessions.length === 0) return + + const output = args.format === "json" ? formatSessionJSON(sessions) : formatSessionTable(sessions) + + const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table" + + if (shouldPaginate) { + yield* Effect.promise(async () => { + const proc = Process.spawn(pagerCmd(), { + stdin: "pipe", + stdout: "inherit", + stderr: "inherit", }) - } else { - console.log(output) - } - }).pipe(Effect.ensuring(store.dispose(ctx))) + + if (!proc.stdin) { + console.log(output) + return + } + + proc.stdin.write(output) + proc.stdin.end() + await proc.exited + }) + } else { + console.log(output) + } }), }) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 966eb5f662..8bf7b2345c 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -5,7 +5,6 @@ import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" import { InstanceRef } from "@/effect/instance-ref" -import { InstanceStore } from "@/project/instance-store" import { AppRuntime } from "@/effect/app-runtime" interface SessionStats { @@ -70,8 +69,7 @@ export const StatsCommand = effectCmd({ handler: Effect.fn("Cli.stats")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const store = yield* InstanceStore.Service - return yield* run(args, ctx.project).pipe(Effect.ensuring(store.dispose(ctx))) + return yield* run(args, ctx.project) }), }) From 9293cddb3a79e505e701ee173f98ebd84473b206 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 00:43:16 +0000 Subject: [PATCH 066/178] chore: generate --- packages/opencode/src/cli/cmd/debug/ripgrep.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/ripgrep.ts b/packages/opencode/src/cli/cmd/debug/ripgrep.ts index ca95c1d559..8d1cbd2b1e 100644 --- a/packages/opencode/src/cli/cmd/debug/ripgrep.ts +++ b/packages/opencode/src/cli/cmd/debug/ripgrep.ts @@ -22,9 +22,7 @@ const TreeCommand = effectCmd({ handler: Effect.fn("Cli.debug.rg.tree")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - const tree = yield* Effect.orDie( - Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit })), - ) + const tree = yield* Effect.orDie(Ripgrep.Service.use((svc) => svc.tree({ cwd: ctx.directory, limit: args.limit }))) process.stdout.write(tree + EOL) }), }) From e709dc34fb795dfa35d49d67673baa7b0f56dac8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 20:43:23 -0400 Subject: [PATCH 067/178] feat: default HTTP API backend to on for dev/beta channels --- packages/core/src/flag/flag.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a3b8133b64..ed52f90e60 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,4 +1,5 @@ import { Config } from "effect" +import { InstallationChannel } from "../installation/version" function truthy(key: string) { const value = process.env[key]?.toLowerCase() @@ -10,6 +11,10 @@ function falsy(key: string) { return value === "false" || value === "0" } +// Channels that default to the new effect-httpapi server backend. The legacy +// hono backend remains the default for stable (`prod`/`latest`) installs. +const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"]) + function number(key: string) { const value = process.env[key] if (!value) return undefined @@ -81,7 +86,14 @@ export const Flag = { OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"), OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], - OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"), + // Defaults to true on dev/beta/local channels so internal users exercise the + // new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay + // on the legacy hono backend until the rollout is complete. An explicit env + // var ("true"/"1" or "false"/"0") always wins, providing an opt-in for + // stable users and an escape hatch for dev/beta users. + OPENCODE_EXPERIMENTAL_HTTPAPI: + truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") || + (!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), // Evaluated at access time (not module load) because tests, the CLI, and From e98c291866f4b3e48caa3dbeb39386dd884a45bd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 21:44:06 -0400 Subject: [PATCH 068/178] feat(cli): add instance: false opt-out to effectCmd (#25507) --- packages/opencode/src/cli/cmd/serve.ts | 19 ++++++----- packages/opencode/src/cli/effect-cmd.ts | 42 +++++++++++++++++++------ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 5f3211aa1c..a8a7234d9a 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,21 +1,24 @@ +import { Effect } from "effect" import { Server } from "../../server/server" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" -export const ServeCommand = cmd({ +export const ServeCommand = effectCmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), describe: "starts a headless opencode server", - handler: async (args) => { + // Server loads instances per-request via x-opencode-directory header — no + // need for an ambient project InstanceContext at startup. + instance: false, + handler: Effect.fn("Cli.serve")(function* (args) { if (!Flag.OPENCODE_SERVER_PASSWORD) { console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) - await new Promise(() => {}) - await server.stop() - }, + yield* Effect.never + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index 6785e0b612..94ad0232cf 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -18,6 +18,34 @@ export class CliError extends Schema.TaggedErrorClass()("CliError", { export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode })) +interface EffectCmdOpts { + command: string | readonly string[] + aliases?: string | readonly string[] + describe: string | false + builder?: (yargs: Argv) => Argv + /** + * Whether the command needs a project InstanceContext. Defaults to true. + * + * `true` (default): wraps the handler in `InstanceStore.Service.provide({directory})` + * so `InstanceRef` resolves to a loaded `InstanceContext`. Auto-disposes via + * `Effect.ensuring(store.dispose(ctx))` on every Exit (matches the legacy + * `bootstrap()` finally-disposal). Runs InstanceBootstrap (config + plugin + * init + LSP/File/etc forks) eagerly. + * + * `false`: skip the instance entirely. Saves the InstanceBootstrap work and + * suppresses the `server.instance.disposed` IPC event. The handler runs + * directly under AppRuntime — it can yield any `AppServices` but must not + * yield `InstanceRef` (it'd be undefined, causing a defect). + * + * Use `false` for commands that don't read project state (e.g. `models`, + * `serve`, `web`, `account`, `db`, `upgrade`). + */ + instance?: boolean + /** Defaults to process.cwd(). Override for commands that take a directory positional. */ + directory?: (args: Args) => string + handler: (args: Args) => Effect.Effect +} + /** * Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is * an `Effect` with `InstanceRef` provided and any `AppServices` yieldable. @@ -35,15 +63,7 @@ export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError( * `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's * `Command.make(...)` won't touch any handler bodies. */ -export const effectCmd = (opts: { - command: string | readonly string[] - aliases?: string | readonly string[] - describe: string | false - builder?: (yargs: Argv) => Argv - /** Defaults to process.cwd(). Override for commands that take a directory positional. */ - directory?: (args: Args) => string - handler: (args: Args) => Effect.Effect -}) => +export const effectCmd = (opts: EffectCmdOpts) => cmd<{}, Args>({ command: opts.command, aliases: opts.aliases, @@ -52,6 +72,10 @@ export const effectCmd = (opts: { async handler(rawArgs) { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. const args = rawArgs as unknown as Args + if (opts.instance === false) { + await AppRuntime.runPromise(opts.handler(args)) + return + } const directory = opts.directory?.(args) ?? process.cwd() await AppRuntime.runPromise( InstanceStore.Service.use((store) => From 1409a0715cd9f0bd92b9c1b736055791f336324c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 21:59:35 -0400 Subject: [PATCH 069/178] refactor(cli): convert web + account to effectCmd (instance: false) (#25512) --- packages/opencode/src/cli/cmd/account.ts | 47 +++++++++++++----------- packages/opencode/src/cli/cmd/web.ts | 19 ++++++---- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index 38c28032cd..e0755577b6 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -3,7 +3,7 @@ import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" import { Account } from "@/account/account" import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema" -import { AppRuntime } from "@/effect/app-runtime" +import { effectCmd } from "../effect-cmd" import * as Prompt from "../effect/prompt" import open from "open" @@ -172,60 +172,65 @@ const openEffect = Effect.fn("open")(function* () { yield* Prompt.outro("Opened " + url) }) -export const LoginCommand = cmd({ +export const LoginCommand = effectCmd({ command: "login ", describe: false, + instance: false, builder: (yargs) => yargs.positional("url", { describe: "server URL", type: "string", demandOption: true, }), - async handler(args) { + handler: Effect.fn("Cli.account.login")(function* (args) { UI.empty() - await AppRuntime.runPromise(loginEffect(args.url)) - }, + yield* Effect.orDie(loginEffect(args.url)) + }), }) -export const LogoutCommand = cmd({ +export const LogoutCommand = effectCmd({ command: "logout [email]", describe: false, + instance: false, builder: (yargs) => yargs.positional("email", { describe: "account email to log out from", type: "string", }), - async handler(args) { + handler: Effect.fn("Cli.account.logout")(function* (args) { UI.empty() - await AppRuntime.runPromise(logoutEffect(args.email)) - }, + yield* Effect.orDie(logoutEffect(args.email)) + }), }) -export const SwitchCommand = cmd({ +export const SwitchCommand = effectCmd({ command: "switch", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.switch")(function* () { UI.empty() - await AppRuntime.runPromise(switchEffect()) - }, + yield* Effect.orDie(switchEffect()) + }), }) -export const OrgsCommand = cmd({ +export const OrgsCommand = effectCmd({ command: "orgs", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.orgs")(function* () { UI.empty() - await AppRuntime.runPromise(orgsEffect()) - }, + yield* Effect.orDie(orgsEffect()) + }), }) -export const OpenCommand = cmd({ +export const OpenCommand = effectCmd({ command: "open", describe: false, - async handler() { + instance: false, + handler: Effect.fn("Cli.account.open")(function* () { UI.empty() - await AppRuntime.runPromise(openEffect()) - }, + yield* Effect.orDie(openEffect()) + }), }) export const ConsoleCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 19ee38ff53..f20381a014 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -1,6 +1,7 @@ +import { Effect } from "effect" import { Server } from "../../server/server" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" import open from "open" @@ -28,16 +29,19 @@ function getNetworkIPs() { return results } -export const WebCommand = cmd({ +export const WebCommand = effectCmd({ command: "web", builder: (yargs) => withNetworkOptions(yargs), describe: "start opencode server and open web interface", - handler: async (args) => { + // Server loads instances per-request via x-opencode-directory header — no + // ambient project InstanceContext needed at startup. + instance: false, + handler: Effect.fn("Cli.web")(function* (args) { if (!Flag.OPENCODE_SERVER_PASSWORD) { UI.println(UI.Style.TEXT_WARNING_BOLD + "! OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) UI.empty() UI.println(UI.logo(" ")) UI.empty() @@ -75,7 +79,6 @@ export const WebCommand = cmd({ open(displayUrl).catch(() => {}) } - await new Promise(() => {}) - await server.stop() - }, + yield* Effect.never + }), }) From a3bc5d35b0f8f542d4531193b8816bc8b55363e3 Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 2 May 2026 22:09:48 -0400 Subject: [PATCH 070/178] Refactor v2 session events as schemas (#24512) --- packages/core/src/flag/flag.ts | 1 + packages/core/src/util/log.ts | 2 + .../migration.sql | 17 + .../snapshot.json | 1481 +++++ .../snapshot.json | 176 +- .../20260501142318_next_venus/migration.sql | 2 + .../20260501142318_next_venus/snapshot.json | 1511 +++++ packages/opencode/src/bus/bus-event.ts | 2 + packages/opencode/src/bus/global.ts | 14 +- packages/opencode/src/bus/index.ts | 25 +- packages/opencode/src/cli/cmd/tui/app.tsx | 45 +- .../cli/cmd/tui/component/prompt/index.tsx | 12 +- .../src/cli/cmd/tui/context/sync-v2.tsx | 298 + .../tui/feature-plugins/system/session-v2.tsx | 1087 +++ .../src/cli/cmd/tui/plugin/internal.ts | 3 + packages/opencode/src/server/routes/global.ts | 3 + .../src/server/routes/instance/event.ts | 8 +- .../src/server/routes/instance/httpapi/api.ts | 2 + .../server/routes/instance/httpapi/event.ts | 4 +- .../routes/instance/httpapi/groups/v2.ts | 14 + .../instance/httpapi/groups/v2/message.ts | 69 + .../instance/httpapi/groups/v2/session.ts | 140 + .../instance/httpapi/handlers/global.ts | 5 +- .../routes/instance/httpapi/handlers/v2.ts | 6 + .../instance/httpapi/handlers/v2/message.ts | 60 + .../instance/httpapi/handlers/v2/session.ts | 115 + .../server/routes/instance/httpapi/server.ts | 2 + .../src/server/routes/instance/index.ts | 128 +- packages/opencode/src/session/compaction.ts | 24 +- packages/opencode/src/session/processor.ts | 143 +- .../opencode/src/session/projectors-next.ts | 204 + packages/opencode/src/session/projectors.ts | 5 +- packages/opencode/src/session/prompt.ts | 119 +- packages/opencode/src/session/session.sql.ts | 25 +- packages/opencode/src/session/session.ts | 29 + packages/opencode/src/sync/index.ts | 8 +- packages/opencode/src/util/effect-zod.ts | 2 +- packages/opencode/src/v2/event.ts | 53 + packages/opencode/src/v2/schema.ts | 10 + .../opencode/src/v2/session-entry-stepper.ts | 261 - packages/opencode/src/v2/session-entry.ts | 220 - packages/opencode/src/v2/session-event.ts | 701 +- .../src/v2/session-message-updater.ts | 411 ++ packages/opencode/src/v2/session-message.ts | 178 + packages/opencode/src/v2/session-prompt.ts | 36 + packages/opencode/src/v2/session.ts | 302 +- packages/opencode/src/v2/tool-output.ts | 18 + .../test/acp/event-subscription.test.ts | 1 + .../opencode/test/cli/tui/use-event.test.tsx | 2 + packages/opencode/test/preload.ts | 3 +- .../test/server/httpapi-bridge.test.ts | 9 +- .../test/server/httpapi-event.test.ts | 15 +- .../test/server/httpapi-session.test.ts | 43 +- .../opencode/test/session/compaction.test.ts | 10 + packages/opencode/test/session/prompt.test.ts | 44 + .../session/session-entry-stepper.test.ts | 916 --- packages/opencode/test/sync/index.test.ts | 2 +- .../test/v2/session-message-updater.test.ts | 203 + packages/sdk/js/script/build.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2997 +++++---- packages/sdk/js/src/v2/gen/types.gen.ts | 5801 ++++++++++------- specs/v2/session-concepts-gap.md | 131 + 62 files changed, 12473 insertions(+), 5687 deletions(-) create mode 100644 packages/opencode/migration/20260427172553_slow_nightmare/migration.sql create mode 100644 packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json create mode 100644 packages/opencode/migration/20260501142318_next_venus/migration.sql create mode 100644 packages/opencode/migration/20260501142318_next_venus/snapshot.json create mode 100644 packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts create mode 100644 packages/opencode/src/session/projectors-next.ts create mode 100644 packages/opencode/src/v2/event.ts create mode 100644 packages/opencode/src/v2/schema.ts delete mode 100644 packages/opencode/src/v2/session-entry-stepper.ts delete mode 100644 packages/opencode/src/v2/session-entry.ts create mode 100644 packages/opencode/src/v2/session-message-updater.ts create mode 100644 packages/opencode/src/v2/session-message.ts create mode 100644 packages/opencode/src/v2/session-prompt.ts create mode 100644 packages/opencode/src/v2/tool-output.ts delete mode 100644 packages/opencode/test/session/session-entry-stepper.test.ts create mode 100644 packages/opencode/test/v2/session-message-updater.test.ts create mode 100644 specs/v2/session-concepts-gap.md diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index ed52f90e60..0daae55800 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -95,6 +95,7 @@ export const Flag = { truthy("OPENCODE_EXPERIMENTAL_HTTPAPI") || (!falsy("OPENCODE_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)), OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"), + OPENCODE_EXPERIMENTAL_EVENT_SYSTEM: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), // Evaluated at access time (not module load) because tests, the CLI, and // external tooling set these env vars at runtime. diff --git a/packages/core/src/util/log.ts b/packages/core/src/util/log.ts index a61c15f7a7..e1962aed4c 100644 --- a/packages/core/src/util/log.ts +++ b/packages/core/src/util/log.ts @@ -1,3 +1,5 @@ +export * as Log from "./log" + import path from "path" import fs from "fs/promises" import { createWriteStream } from "fs" diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql new file mode 100644 index 0000000000..d5efe5f9e8 --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE `session_message` ( + `id` text PRIMARY KEY, + `session_id` text NOT NULL, + `type` text NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL, + `data` text NOT NULL, + CONSTRAINT `fk_session_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE +); +--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_session_type_idx`;--> statement-breakpoint +DROP INDEX IF EXISTS `session_entry_time_created_idx`;--> statement-breakpoint +CREATE INDEX `session_message_session_idx` ON `session_message` (`session_id`);--> statement-breakpoint +CREATE INDEX `session_message_session_type_idx` ON `session_message` (`session_id`,`type`);--> statement-breakpoint +CREATE INDEX `session_message_time_created_idx` ON `session_message` (`time_created`);--> statement-breakpoint +DROP TABLE `session_entry`; \ No newline at end of file diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json new file mode 100644 index 0000000000..bb6d06237e --- /dev/null +++ b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json @@ -0,0 +1,1481 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "61f807f9-6398-4067-be05-804acc2561bc", + "prevIds": [ + "66cbe0d7-def0-451b-b88a-7608513a9b44" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json index d79324fedf..1f3bc493c1 100644 --- a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json +++ b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json @@ -2,7 +2,9 @@ "version": "7", "dialect": "sqlite", "id": "aaa2ebeb-caa4-478d-8365-4fc595d16856", - "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"], + "prevIds": [ + "61f807f9-6398-4067-be05-804acc2561bc" + ], "ddl": [ { "name": "account_state", @@ -37,7 +39,7 @@ "entityType": "tables" }, { - "name": "session_entry", + "name": "session_message", "entityType": "tables" }, { @@ -598,7 +600,7 @@ "generated": null, "name": "id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -608,7 +610,7 @@ "generated": null, "name": "session_id", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -618,7 +620,7 @@ "generated": null, "name": "type", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -628,7 +630,7 @@ "generated": null, "name": "time_created", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "integer", @@ -638,7 +640,7 @@ "generated": null, "name": "time_updated", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -648,7 +650,7 @@ "generated": null, "name": "data", "entityType": "columns", - "table": "session_entry" + "table": "session_message" }, { "type": "text", @@ -1051,9 +1053,13 @@ "table": "event" }, { - "columns": ["active_account_id"], + "columns": [ + "active_account_id" + ], "tableTo": "account", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1062,9 +1068,13 @@ "table": "account_state" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1073,9 +1083,13 @@ "table": "workspace" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1084,9 +1098,13 @@ "table": "message" }, { - "columns": ["message_id"], + "columns": [ + "message_id" + ], "tableTo": "message", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1095,9 +1113,13 @@ "table": "part" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1106,20 +1128,28 @@ "table": "permission" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, - "name": "fk_session_entry_session_id_session_id_fk", + "name": "fk_session_message_session_id_session_id_fk", "entityType": "fks", - "table": "session_entry" + "table": "session_message" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1128,9 +1158,13 @@ "table": "session" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1139,9 +1173,13 @@ "table": "todo" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1150,9 +1188,13 @@ "table": "session_share" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "tableTo": "event_sequence", - "columnsTo": ["aggregate_id"], + "columnsTo": [ + "aggregate_id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1161,98 +1203,128 @@ "table": "event" }, { - "columns": ["email", "url"], + "columns": [ + "email", + "url" + ], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": ["session_id", "position"], + "columns": [ + "session_id", + "position" + ], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, - "name": "session_entry_pk", - "table": "session_entry", + "name": "session_message_pk", + "table": "session_message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1322,9 +1394,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_idx", + "name": "session_message_session_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1340,9 +1412,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_session_type_idx", + "name": "session_message_session_type_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ @@ -1354,9 +1426,9 @@ "isUnique": false, "where": null, "origin": "manual", - "name": "session_entry_time_created_idx", + "name": "session_message_time_created_idx", "entityType": "indexes", - "table": "session_entry" + "table": "session_message" }, { "columns": [ diff --git a/packages/opencode/migration/20260501142318_next_venus/migration.sql b/packages/opencode/migration/20260501142318_next_venus/migration.sql new file mode 100644 index 0000000000..e0ffe7823c --- /dev/null +++ b/packages/opencode/migration/20260501142318_next_venus/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `session` ADD `agent` text;--> statement-breakpoint +ALTER TABLE `session` ADD `model` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260501142318_next_venus/snapshot.json b/packages/opencode/migration/20260501142318_next_venus/snapshot.json new file mode 100644 index 0000000000..e594de2f04 --- /dev/null +++ b/packages/opencode/migration/20260501142318_next_venus/snapshot.json @@ -0,0 +1,1511 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "2ec89846-dcf1-4977-ab5e-244ddc9e3d67", + "prevIds": [ + "aaa2ebeb-caa4-478d-8365-4fc595d16856" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index cf9fcfbeec..3250c166ab 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -24,6 +24,7 @@ export function payloads() { .map(([type, def]) => { return z .object({ + id: z.string(), type: z.literal(type), properties: zodObject(def.properties), }) @@ -39,6 +40,7 @@ export function effectPayloads() { .entries() .map(([type, def]) => Schema.Struct({ + id: Schema.String, type: Schema.Literal(type), properties: def.properties, }).annotate({ identifier: `Event.${type}` }), diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index b5392a81b9..3cfd453624 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "events" +import { Identifier } from "@/id/id" export type GlobalEvent = { directory?: string @@ -7,6 +8,15 @@ export type GlobalEvent = { payload: any } -export const GlobalBus = new EventEmitter<{ +class GlobalBusEmitter extends EventEmitter<{ event: [GlobalEvent] -}>() +}> { + override emit(eventName: "event", event: GlobalEvent): boolean { + if (event.payload && typeof event.payload === "object" && !("id" in event.payload)) { + event.payload.id = event.payload.syncEvent?.id ?? Identifier.create("evt", "ascending") + } + return super.emit(eventName, event) + } +} + +export const GlobalBus = new GlobalBusEmitter() diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index 9ee8e6fb03..449694a53a 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -5,6 +5,7 @@ import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" +import { Identifier } from "@/id/id" const log = Log.create({ service: "bus" }) @@ -18,6 +19,7 @@ export const InstanceDisposed = BusEvent.define( ) type Payload = { + id: string type: D["type"] properties: BusProperties } @@ -28,7 +30,11 @@ type State = { } export interface Interface { - readonly publish: (def: D, properties: BusProperties) => Effect.Effect + readonly publish: ( + def: D, + properties: BusProperties, + options?: { id?: string }, + ) => Effect.Effect readonly subscribe: (def: D) => Stream.Stream> readonly subscribeAll: () => Stream.Stream readonly subscribeCallback: ( @@ -53,6 +59,7 @@ export const layer = Layer.effect( // Publish InstanceDisposed before shutting down so subscribers see it yield* PubSub.publish(wildcard, { type: InstanceDisposed.type, + id: createID(), properties: { directory: ctx.directory }, }) yield* PubSub.shutdown(wildcard) @@ -77,10 +84,10 @@ export const layer = Layer.effect( }) } - function publish(def: D, properties: BusProperties) { + function publish(def: D, properties: BusProperties, options?: { id?: string }) { return Effect.gen(function* () { const s = yield* InstanceState.get(state) - const payload: Payload = { type: def.type, properties } + const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties } log.info("publishing", { type: def.type }) const ps = s.typed.get(def.type) @@ -173,8 +180,16 @@ const { runPromise, runSync } = makeRuntime(Service, layer) // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, // Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw. -export async function publish(def: D, properties: BusProperties) { - return runPromise((svc) => svc.publish(def, properties)) +export function createID() { + return Identifier.create("evt", "ascending") +} + +export async function publish( + def: D, + properties: BusProperties, + options?: { id?: string }, +) { + return runPromise((svc) => svc.publish(def, properties, options)) } export function subscribe(def: D, callback: (event: Payload) => unknown) { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 7117ae7d1b..ea742f6997 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -28,6 +28,7 @@ import { useEvent } from "@tui/context/event" import { SDKProvider, useSDK } from "@tui/context/sdk" import { StartupLoading } from "@tui/component/startup-loading" import { SyncProvider, useSync } from "@tui/context/sync" +import { SyncProviderV2 } from "@tui/context/sync-v2" import { LocalProvider, useLocal } from "@tui/context/local" import { DialogModel } from "@tui/component/dialog-model" import { useConnected } from "@tui/component/use-connected" @@ -168,27 +169,29 @@ export function tui(input: { > - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 79034a01bb..a6ba797f33 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -750,9 +750,18 @@ export function Prompt(props: PromptProps) { return false } + const variant = local.model.variant.current() let sessionID = props.sessionID if (sessionID == null) { - const res = await sdk.client.session.create({ workspace: props.workspaceID }) + const res = await sdk.client.session.create({ + workspace: props.workspaceID, + agent: agent.name, + model: { + providerID: selectedModel.providerID, + id: selectedModel.modelID, + variant, + }, + }) if (res.error) { console.log("Creating a session failed:", res.error) @@ -792,7 +801,6 @@ export function Prompt(props: PromptProps) { // Capture mode before it gets reset const currentMode = store.mode - const variant = local.model.variant.current() const editorSelection = editorContext() const currentEditorSelectionKey = editorSelectionKey(editorSelection) const editorParts = diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx new file mode 100644 index 0000000000..f82bb4d962 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -0,0 +1,298 @@ +import { useEvent } from "@tui/context/event" +import type { + SessionMessage, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, +} from "@opencode-ai/sdk/v2" +import { createStore, produce, reconcile } from "solid-js/store" +import { createSimpleContext } from "./helper" +import { useSDK } from "./sdk" + +function activeAssistant(messages: SessionMessage[]) { + const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + if (index < 0) return + const assistant = messages[index] + return assistant?.type === "assistant" ? assistant : undefined +} + +function activeCompaction(messages: SessionMessage[]) { + const index = messages.findLastIndex((message) => message.type === "compaction") + if (index < 0) return + const compaction = messages[index] + return compaction?.type === "compaction" ? compaction : undefined +} + +function activeShell(messages: SessionMessage[], callID: string) { + const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + if (index < 0) return + const shell = messages[index] + return shell?.type === "shell" ? shell : undefined +} + +function latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantTool => item.type === "tool" && (callID === undefined || item.id === callID), + ) +} + +function latestText(assistant: SessionMessageAssistant | undefined) { + return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text") +} + +function latestReasoning(assistant: SessionMessageAssistant | undefined, reasoningID: string) { + return assistant?.content.findLast( + (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && item.id === reasoningID, + ) +} + +export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext({ + name: "SyncV2", + init: () => { + const [store, setStore] = createStore<{ + messages: { + [sessionID: string]: SessionMessage[] + } + }>({ + messages: {}, + }) + + const event = useEvent() + const sdk = useSDK() + + function update(sessionID: string, fn: (messages: SessionMessage[]) => void) { + setStore( + "messages", + produce((draft) => { + fn((draft[sessionID] ??= [])) + }), + ) + } + + event.subscribe((event) => { + switch (event.type) { + case "session.next.prompted": { + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "user", + text: event.properties.prompt.text, + files: event.properties.prompt.files, + agents: event.properties.prompt.agents, + time: { created: event.properties.timestamp }, + }) + }) + break + } + case "session.next.synthetic": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "synthetic", + sessionID: event.properties.sessionID, + text: event.properties.text, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.started": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "shell", + callID: event.properties.callID, + command: event.properties.command, + output: "", + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.shell.ended": + update(event.properties.sessionID, (draft) => { + const match = activeShell(draft, event.properties.callID) + if (!match) return + match.output = event.properties.output + match.time.completed = event.properties.timestamp + }) + break + case "session.next.step.started": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp + draft.push({ + id: event.id, + type: "assistant", + agent: event.properties.agent, + model: event.properties.model, + content: [], + snapshot: event.properties.snapshot ? { start: event.properties.snapshot } : undefined, + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.step.ended": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = event.properties.finish + currentAssistant.cost = event.properties.cost + currentAssistant.tokens = event.properties.tokens + if (event.properties.snapshot) + currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } + }) + break + case "session.next.text.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ type: "text", text: "" }) + }) + break + case "session.next.text.delta": + update(event.properties.sessionID, (draft) => { + const match = latestText(activeAssistant(draft)) + if (match) match.text += event.properties.delta + }) + break + case "session.next.text.ended": + update(event.properties.sessionID, (draft) => { + const match = latestText(activeAssistant(draft)) + if (match) match.text = event.properties.text + }) + break + case "session.next.tool.input.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ + type: "tool", + id: event.properties.callID, + name: event.properties.name, + time: { created: event.properties.timestamp }, + state: { status: "pending", input: "" }, + }) + }) + break + case "session.next.tool.input.delta": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status === "pending") match.state.input += event.properties.delta + }) + break + case "session.next.tool.input.ended": + break + case "session.next.tool.called": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (!match) return + match.time.ran = event.properties.timestamp + match.provider = event.properties.provider + match.state = { status: "running", input: event.properties.input, structured: {}, content: [] } + }) + break + case "session.next.tool.progress": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state.structured = event.properties.structured + match.state.content = [...event.properties.content] + }) + break + case "session.next.tool.success": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state = { + status: "completed", + input: match.state.input, + structured: event.properties.structured, + content: [...event.properties.content], + } + match.provider = event.properties.provider + match.time.completed = event.properties.timestamp + }) + break + case "session.next.tool.error": + update(event.properties.sessionID, (draft) => { + const match = latestTool(activeAssistant(draft), event.properties.callID) + if (match?.state.status !== "running") return + match.state = { + status: "error", + error: event.properties.error, + input: match.state.input, + structured: match.state.structured, + content: match.state.content, + } + match.provider = event.properties.provider + match.time.completed = event.properties.timestamp + }) + break + case "session.next.reasoning.started": + update(event.properties.sessionID, (draft) => { + activeAssistant(draft)?.content.push({ + type: "reasoning", + id: event.properties.reasoningID, + text: "", + }) + }) + break + case "session.next.reasoning.delta": + update(event.properties.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + if (match) match.text += event.properties.delta + }) + break + case "session.next.reasoning.ended": + update(event.properties.sessionID, (draft) => { + const match = latestReasoning(activeAssistant(draft), event.properties.reasoningID) + if (match) match.text = event.properties.text + }) + break + case "session.next.retried": + break + case "session.next.compaction.started": + update(event.properties.sessionID, (draft) => { + draft.push({ + id: event.id, + type: "compaction", + reason: event.properties.reason, + summary: "", + time: { created: event.properties.timestamp }, + }) + }) + break + case "session.next.compaction.delta": + update(event.properties.sessionID, (draft) => { + const match = activeCompaction(draft) + if (match) match.summary += event.properties.text + }) + break + case "session.next.compaction.ended": + update(event.properties.sessionID, (draft) => { + const match = activeCompaction(draft) + if (!match) return + match.summary = event.properties.text + match.include = event.properties.include + }) + break + } + }) + + const result = { + data: store, + session: { + message: { + async sync(sessionID: string) { + const response = await sdk.client.v2.session.messages({ sessionID }) + setStore("messages", sessionID, reconcile(response.data?.items ?? [])) + }, + fromSession(sessionID: string) { + const messages = store.messages[sessionID] + if (!messages) return [] + return messages + }, + }, + }, + } + + return result + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx new file mode 100644 index 0000000000..7270a9c3b7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -0,0 +1,1087 @@ +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import { useSyncV2 } from "@tui/context/sync-v2" +import { SplitBorder } from "@tui/component/border" +import { Spinner } from "@tui/component/spinner" +import { useTheme } from "@tui/context/theme" +import { useLocal } from "@tui/context/local" +import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" +import type { SyntaxStyle } from "@opentui/core" +import { Locale } from "@/util/locale" +import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import path from "path" +import stripAnsi from "strip-ansi" +import type { + SessionMessage, + SessionMessageAgentSwitched, + SessionMessageAssistant, + SessionMessageAssistantReasoning, + SessionMessageAssistantText, + SessionMessageAssistantTool, + SessionMessageCompaction, + SessionMessageModelSwitched, + SessionMessageShell, + SessionMessageSynthetic, + SessionMessageUser, + ToolFileContent, + ToolTextContent, +} from "@opencode-ai/sdk/v2" +import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" + +const id = "internal:session-v2-debug" +const route = "session.v2.messages" + +function currentSessionID(api: TuiPluginApi) { + const current = api.route.current + if (current.name !== "session") return + const sessionID = current.params?.sessionID + return typeof sessionID === "string" ? sessionID : undefined +} + +function View(props: { api: TuiPluginApi; sessionID: string }) { + const sync = useSyncV2() + const dimensions = useTerminalDimensions() + const { theme, syntax, subtleSyntax } = useTheme() + const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) + const renderedMessages = createMemo(() => messages().toReversed()) + const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) + + createEffect(() => { + void sync.session.message.sync(props.sessionID) + }) + + useKeyboard((event) => { + if (event.name !== "escape") return + event.preventDefault() + event.stopPropagation() + props.api.route.navigate("session", { sessionID: props.sessionID }) + }) + + return ( + + + + + + + + + + {(message, index) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + + + + + + ) +} + +function MissingData(props: { label: string; detail: string }) { + const { theme } = useTheme() + return ( + + + MISSING DATA {props.label} + + {props.detail} + + ) +} + +function UserMessage(props: { message: SessionMessageUser; index: number }) { + const { theme } = useTheme() + const attachments = createMemo(() => [...(props.message.files ?? []), ...(props.message.agents ?? [])]) + return ( + + + + } + > + {props.message.text} + + + + + {(file) => ( + + {file.mime} + {file.name ?? file.uri} + + )} + + + {(agent) => ( + + agent + {agent.name} + + )} + + + + {Locale.todayTimeOrDateTime(props.message.time.created)} + + + ) +} + +function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) { + const { theme } = useTheme() + return ( + + Synthetic + {props.message.text} + + ) +} + +function ShellMessage(props: { message: SessionMessageShell }) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi(props.message.output.trim())) + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const overflow = createMemo(() => lines().length > 10) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, 10), "…"].join("\n") + }) + return ( + setExpanded((prev) => !prev) : undefined} + > + + $ {props.message.command} + + {limited()} + + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + ) +} + +function CompactionMessage(props: { message: SessionMessageCompaction }) { + const { theme } = useTheme() + return ( + + + {props.message.summary} + + + ) +} + +function AgentSwitchedMessage(props: { message: SessionMessageAgentSwitched }) { + const { theme } = useTheme() + const local = useLocal() + return ( + + + + Switched agent to + {Locale.titlecase(props.message.agent)} + + + ) +} + +function ModelSwitchedMessage(props: { message: SessionMessageModelSwitched }) { + const { theme } = useTheme() + const model = createMemo(() => { + const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" + return `${props.message.model.providerID}/${props.message.model.id}${variant}` + }) + return ( + + + + Switched model to + {model()} + + + ) +} + +function UnknownMessage(props: { message: SessionMessage }) { + return +} + +function AssistantMessage(props: { + message: SessionMessageAssistant + last: boolean + syntax: SyntaxStyle + subtleSyntax: SyntaxStyle +}) { + const { theme } = useTheme() + const local = useLocal() + const duration = createMemo(() => { + if (!props.message.time.completed) return 0 + return props.message.time.completed - props.message.time.created + }) + const model = createMemo(() => { + const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" + return `${props.message.model.providerID}/${props.message.model.id}${variant}` + }) + const final = createMemo(() => props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)) + return ( + <> + + {(part) => ( + + + + + + + + + + + + )} + + + + + + + {props.message.error} + + + + + + + {Locale.titlecase(props.message.agent)} + · {model()} + + · {Locale.duration(duration())} + + + + + + ) +} + +function AssistantText(props: { part: SessionMessageAssistantText; syntax: SyntaxStyle }) { + const { theme } = useTheme() + return ( + + + + + + ) +} + +function AssistantReasoning(props: { part: SessionMessageAssistantReasoning; subtleSyntax: SyntaxStyle }) { + const { theme } = useTheme() + const content = createMemo(() => props.part.text.replace("[REDACTED]", "").trim()) + return ( + + + + + + ) +} + +function AssistantTool(props: { part: SessionMessageAssistantTool }) { + const input = createMemo(() => toolInputRecord(props.part.state.input)) + const toolprops = { + get input() { + return input() + }, + get metadata() { + return props.part.provider?.metadata ?? {} + }, + get output() { + return props.part.state.status === "pending" ? undefined : toolOutput(props.part.state.content) + }, + part: props.part, + } + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +type ToolProps = { + input: Record + metadata: Record + output?: string + part: SessionMessageAssistantTool +} + +function GenericTool(props: ToolProps) { + const { theme } = useTheme() + const output = createMemo(() => props.output?.trim() ?? "") + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const maxLines = 3 + const overflow = createMemo(() => lines().length > maxLines) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, maxLines), "…"].join("\n") + }) + return ( + + {props.part.name} {input(props.input)} + + } + > + setExpanded((prev) => !prev) : undefined} + > + + {limited()} + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + + ) +} + +function InlineTool(props: { + icon: string + complete: unknown + pending: string + spinner?: boolean + children: JSX.Element + part: SessionMessageAssistantTool +}) { + const { theme } = useTheme() + const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) + const denied = createMemo(() => { + const message = error() + if (!message) return false + return ( + message.includes("QuestionRejectedError") || + message.includes("rejected permission") || + message.includes("user dismissed") + ) + }) + return ( + + + + {props.children} + + + + ~ {props.pending}} when={props.complete}> + {props.icon} {props.children} + + + + + + {error()} + + + ) +} + +function BlockTool(props: { + title: string + children: JSX.Element + part?: SessionMessageAssistantTool + onClick?: () => void + spinner?: boolean +}) { + const { theme } = useTheme() + const renderer = useRenderer() + const [hover, setHover] = createSignal(false) + const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error.message : undefined)) + return ( + props.onClick && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + props.onClick?.() + }} + flexShrink={0} + > + + {props.title} + + } + > + {props.title.replace(/^# /, "")} + + {props.children} + + {error()} + + + ) +} + +function Bash(props: ToolProps) { + const { theme } = useTheme() + const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim())) + const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part)) + const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`) + const [expanded, setExpanded] = createSignal(false) + const lines = createMemo(() => output().split("\n")) + const overflow = createMemo(() => lines().length > 10) + const limited = createMemo(() => { + if (expanded() || !overflow()) return output() + return [...lines().slice(0, 10), "…"].join("\n") + }) + return ( + + + setExpanded((prev) => !prev) : undefined} + > + + $ {command()} + {limited()} + + {expanded() ? "Click to collapse" : "Click to expand"} + + + + + + + {command()} + + + + ) +} + +function Glob(props: ToolProps) { + return ( + + Glob "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} + in {normalizePath(stringValue(props.input.path))} + + {(count) => ( + <> + ({count()} {count() === 1 ? "match" : "matches"}) + + )} + + + ) +} + +function Read(props: ToolProps) { + const { theme } = useTheme() + const loaded = createMemo(() => + arrayValue(props.metadata.loaded).filter((item): item is string => typeof item === "string"), + ) + return ( + <> + + Read {normalizePath(stringValue(props.input.filePath) ?? pendingInput(props.part))}{" "} + {input(props.input, ["filePath"])} + + + {(filepath) => ( + + + ↳ Loaded {normalizePath(filepath)} + + + )} + + + ) +} + +function Grep(props: ToolProps) { + return ( + + Grep "{stringValue(props.input.pattern) ?? pendingInput(props.part)}"{" "} + in {normalizePath(stringValue(props.input.path))} + + {(matches) => ( + <> + ({matches()} {matches() === 1 ? "match" : "matches"}) + + )} + + + ) +} + +function WebFetch(props: ToolProps) { + return ( + + WebFetch {stringValue(props.input.url) ?? pendingInput(props.part)} + + ) +} + +function CodeSearch(props: ToolProps) { + return ( + + Exa Code Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {(results) => <>({results()} results)} + + ) +} + +function WebSearch(props: ToolProps) { + return ( + + Exa Web Search "{stringValue(props.input.query) ?? pendingInput(props.part)}"{" "} + {(results) => <>({results()} results)} + + ) +} + +function Write(props: ToolProps) { + const { theme, syntax } = useTheme() + const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") + const content = createMemo(() => stringValue(props.input.content) ?? "") + return ( + + + + + + + + + + + + Write {normalizePath(filePath())} + + + + ) +} + +function Edit(props: ToolProps) { + const { theme, syntax } = useTheme() + const dimensions = useTerminalDimensions() + const filePath = createMemo(() => stringValue(props.input.filePath) ?? "") + const diff = createMemo(() => stringValue(props.metadata.diff)) + return ( + + + {(diff) => ( + + + 120 ? "split" : "unified"} + filetype={filetype(filePath())} + syntaxStyle={syntax()} + showLineNumbers={true} + width="100%" + wrapMode="word" + fg={theme.text} + addedBg={theme.diffAddedBg} + removedBg={theme.diffRemovedBg} + contextBg={theme.diffContextBg} + addedSignColor={theme.diffHighlightAdded} + removedSignColor={theme.diffHighlightRemoved} + lineNumberFg={theme.diffLineNumber} + lineNumberBg={theme.diffContextBg} + addedLineNumberBg={theme.diffAddedLineNumberBg} + removedLineNumberBg={theme.diffRemovedLineNumberBg} + /> + + + + )} + + + + Edit {normalizePath(filePath())} {input({ replaceAll: props.input.replaceAll })} + + + + ) +} + +function ApplyPatch(props: ToolProps) { + const { theme, syntax } = useTheme() + const dimensions = useTerminalDimensions() + const files = createMemo(() => arrayValue(props.metadata.files).flatMap((item) => (isRecord(item) ? [item] : []))) + const fileTitle = (file: Record) => { + const type = stringValue(file.type) + const relativePath = stringValue(file.relativePath) ?? stringValue(file.filePath) ?? "patch" + if (type === "delete") return "# Deleted " + relativePath + if (type === "add") return "# Created " + relativePath + if (type === "move") return "# Moved " + normalizePath(stringValue(file.filePath)) + " → " + relativePath + return "← Patched " + relativePath + } + return ( + + 0}> + + {(file) => ( + + + -{numberValue(file.deletions) ?? 0} line{numberValue(file.deletions) === 1 ? "" : "s"} + + } + > + {(patch) => ( + + 120 ? "split" : "unified"} + filetype={filetype(stringValue(file.filePath) ?? stringValue(file.relativePath))} + syntaxStyle={syntax()} + showLineNumbers={true} + width="100%" + wrapMode="word" + fg={theme.text} + addedBg={theme.diffAddedBg} + removedBg={theme.diffRemovedBg} + contextBg={theme.diffContextBg} + addedSignColor={theme.diffHighlightAdded} + removedSignColor={theme.diffHighlightRemoved} + lineNumberFg={theme.diffLineNumber} + lineNumberBg={theme.diffContextBg} + addedLineNumberBg={theme.diffAddedLineNumberBg} + removedLineNumberBg={theme.diffRemovedLineNumberBg} + /> + + )} + + + )} + + + + + Patch + + + + ) +} + +function TodoWrite(props: ToolProps) { + const { theme } = useTheme() + const todos = createMemo(() => arrayValue(props.input.todos).flatMap((item) => (isRecord(item) ? [item] : []))) + return ( + + 0 && props.part.state.status === "completed"}> + + + + {(todo) => ( + + {todoIcon(stringValue(todo.status))} {stringValue(todo.content)} + + )} + + + + + + + Updating todos... + + + + ) +} + +function Question(props: ToolProps) { + const { theme } = useTheme() + const questions = createMemo(() => + arrayValue(props.input.questions).flatMap((item) => (isRecord(item) ? [item] : [])), + ) + const answers = createMemo(() => arrayValue(props.metadata.answers)) + return ( + + 0}> + + + + {(question, index) => ( + + {stringValue(question.question)} + {formatAnswer(answers()[index()])} + + )} + + + + + + + Asked {questions().length} question{questions().length === 1 ? "" : "s"} + + + + ) +} + +function Skill(props: ToolProps) { + return ( + + Skill "{stringValue(props.input.name) ?? pendingInput(props.part)}" + + ) +} + +function Task(props: ToolProps) { + const content = createMemo(() => { + const description = stringValue(props.input.description) + if (!description) return pendingInput(props.part) + return `${Locale.titlecase(stringValue(props.input.subagent_type) ?? "General")} Task — ${description}` + }) + return ( + + {content()} + + ) +} + +function Diagnostics(props: { diagnostics: unknown; filePath: string }) { + const { theme } = useTheme() + const errors = createMemo(() => { + if (!isRecord(props.diagnostics)) return [] + const value = props.diagnostics[normalizePath(props.filePath)] ?? props.diagnostics[props.filePath] + return arrayValue(value) + .flatMap((item) => (isRecord(item) ? [item] : [])) + .filter((diagnostic) => diagnostic.severity === 1) + .slice(0, 3) + }) + return ( + + + + {(diagnostic) => Error {stringValue(diagnostic.message)}} + + + + ) +} + +function toolOutput(content?: Array) { + return (content ?? []) + .map((item) => { + if (item.type === "text") return item.text.trim() + return `[file ${item.name ?? item.uri}]` + }) + .filter(Boolean) + .join("\n") +} + +function toolInputRecord(input: string | Record) { + if (typeof input === "string") return {} + return input +} + +function pendingInput(part: SessionMessageAssistantTool) { + if (part.state.status !== "pending") return "" + return part.state.input.trim() +} + +function toolComplete(part: SessionMessageAssistantTool) { + if (part.state.status === "pending") return pendingInput(part) + return part.state.status === "completed" || part.state.status === "error" || part.state.status === "running" +} + +function stringValue(value: unknown) { + return typeof value === "string" ? value : undefined +} + +function numberValue(value: unknown) { + return typeof value === "number" ? value : undefined +} + +function arrayValue(value: unknown): unknown[] { + return Array.isArray(value) ? value : [] +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function input(input: Record, omit?: string[]) { + const primitives = Object.entries(input).filter(([key, value]) => { + if (omit?.includes(key)) return false + return typeof value === "string" || typeof value === "number" || typeof value === "boolean" + }) + if (primitives.length === 0) return "" + return `[${primitives.map(([key, value]) => `${key}=${value}`).join(", ")}]` +} + +function normalizePath(input?: string) { + if (!input) return "" + const absolute = path.isAbsolute(input) ? input : path.resolve(process.cwd(), input) + const relative = path.relative(process.cwd(), absolute) + if (!relative) return "." + if (!relative.startsWith("..")) return relative + return absolute +} + +function filetype(input?: string) { + if (!input) return "none" + const language = LANGUAGE_EXTENSIONS[path.extname(input)] + if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" + return language +} + +function todoIcon(status?: string) { + if (status === "completed") return "✓" + if (status === "in_progress") return "~" + if (status === "cancelled") return "✕" + return "☐" +} + +function formatAnswer(answer: unknown) { + if (!Array.isArray(answer)) return "(no answer)" + if (answer.length === 0) return "(no answer)" + return answer.filter((item): item is string => typeof item === "string").join(", ") +} + +const tui: TuiPlugin = async (api) => { + api.route.register([ + { + name: route, + render(input) { + const sessionID = input.params?.sessionID + if (typeof sessionID !== "string") { + return Missing sessionID + } + return + }, + }, + ]) + + api.command.register(() => [ + { + title: "View v2 session messages", + value: route, + category: "Debug", + suggested: api.route.current.name === "session", + enabled: api.route.current.name === "session", + onSelect() { + const sessionID = currentSessionID(api) + if (!sessionID) return + api.route.navigate(route, { sessionID }) + api.ui.dialog.clear() + }, + }, + ]) +} + +const plugin: TuiPluginModule & { id: string } = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 856ee0ebb1..2b0d859192 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -7,7 +7,9 @@ import SidebarTodo from "../feature-plugins/sidebar/todo" import SidebarFiles from "../feature-plugins/sidebar/files" import SidebarFooter from "../feature-plugins/sidebar/footer" import PluginManager from "../feature-plugins/system/plugins" +import SessionV2Debug from "../feature-plugins/system/session-v2" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" +import { Flag } from "@opencode-ai/core/flag/flag" export type InternalTuiPlugin = TuiPluginModule & { id: string @@ -24,4 +26,5 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ SidebarFiles, SidebarFooter, PluginManager, + ...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []), ] diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 4a491d95b6..da3614d228 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -6,6 +6,7 @@ import z from "zod" import { BusEvent } from "@/bus/bus-event" import { SyncEvent } from "@/sync" import { GlobalBus } from "@/bus/global" +import { Bus } from "@/bus" import { AppRuntime } from "@/effect/app-runtime" import { AsyncQueue } from "@/util/queue" import { Installation } from "@/installation" @@ -26,6 +27,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue q.push( JSON.stringify({ payload: { + id: Bus.createID(), type: "server.connected", properties: {}, }, @@ -37,6 +39,7 @@ async function streamEvents(c: Context, subscribe: (q: AsyncQueue q.push( JSON.stringify({ payload: { + id: Bus.createID(), type: "server.heartbeat", properties: {}, }, diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts index 474d92b31b..52e9bc1964 100644 --- a/packages/opencode/src/server/routes/instance/event.ts +++ b/packages/opencode/src/server/routes/instance/event.ts @@ -42,6 +42,7 @@ export const EventRoutes = () => q.push( JSON.stringify({ + id: Bus.createID(), type: "server.connected", properties: {}, }), @@ -50,9 +51,10 @@ export const EventRoutes = () => // Send heartbeat every 10s to prevent stalled proxy streams. const heartbeat = setInterval(() => { q.push( - JSON.stringify({ - type: "server.heartbeat", - properties: {}, + JSON.stringify({ + id: Bus.createID(), + type: "server.heartbeat", + properties: {}, }), ) }, 10_000) diff --git a/packages/opencode/src/server/routes/instance/httpapi/api.ts b/packages/opencode/src/server/routes/instance/httpapi/api.ts index 81ea2394c0..1cf1584e3e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/api.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/api.ts @@ -19,6 +19,7 @@ import { SessionApi } from "./groups/session" import { SyncApi } from "./groups/sync" import { TuiApi } from "./groups/tui" import { WorkspaceApi } from "./groups/workspace" +import { V2Api } from "./groups/v2" // SSE event schemas built from the same BusEvent/SyncEvent registries that // the Hono spec uses, so both specs emit identical Event/SyncEvent components. @@ -40,6 +41,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance") .addHttpApi(ProviderApi) .addHttpApi(SessionApi) .addHttpApi(SyncApi) + .addHttpApi(V2Api) .addHttpApi(TuiApi) .addHttpApi(WorkspaceApi) diff --git a/packages/opencode/src/server/routes/instance/httpapi/event.ts b/packages/opencode/src/server/routes/instance/httpapi/event.ts index 25e810753e..a5c328ac0e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/event.ts @@ -41,12 +41,12 @@ function eventResponse(bus: Bus.Interface) { const events = bus.subscribeAll().pipe(Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type)) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ type: "server.heartbeat", properties: {} })), + Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })), ) log.info("event connected") return HttpServerResponse.stream( - Stream.make({ type: "server.connected", properties: {} }).pipe( + Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts new file mode 100644 index 0000000000..05da5b720d --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2.ts @@ -0,0 +1,14 @@ +import { HttpApi, OpenApi } from "effect/unstable/httpapi" +import { MessageGroup } from "./v2/message" +import { SessionGroup } from "./v2/session" + +export const V2Api = HttpApi.make("v2") + .add(SessionGroup) + .add(MessageGroup) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts new file mode 100644 index 0000000000..3b0b2fa5b1 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -0,0 +1,69 @@ +import { SessionID } from "@/session/schema" +import { SessionMessage } from "@/v2/session-message" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const MessageGroup = HttpApiGroup.make("v2.message") + .add( + HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", { + params: { sessionID: SessionID }, + query: Schema.Union([ + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: + "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Message order for the first page. Use desc for newest first or asc for oldest first.", + }), + cursor: Schema.optional(Schema.Never), + }), + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: + "Maximum number of messages to return. When omitted, the endpoint returns its default page size.", + }), + cursor: Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + order: Schema.optional(Schema.Never), + }), + ]).annotate({ identifier: "V2SessionMessagesQuery" }), + success: Schema.Struct({ + items: Schema.Array(SessionMessage.Message), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "V2SessionMessagesResponse" }), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.messages", + summary: "Get v2 session messages", + description: + "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2 messages", + description: "Experimental v2 message routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts new file mode 100644 index 0000000000..17ddcaeda3 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -0,0 +1,140 @@ +import { WorkspaceID } from "@/control-plane/schema" +import { SessionID } from "@/session/schema" +import { SessionMessage } from "@/v2/session-message" +import { Prompt } from "@/v2/session-prompt" +import { SessionV2 } from "@/v2/session" +import { Schema, SchemaGetter } from "effect" +import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { Authorization } from "../../middleware/authorization" + +export const SessionGroup = HttpApiGroup.make("v2.session") + .add( + HttpApiEndpoint.get("sessions", "/api/session", { + query: Schema.Union([ + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({ + description: "Session order for the first page. Use desc for newest first or asc for oldest first.", + }), + directory: Schema.String.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + workspace: WorkspaceID.pipe(Schema.optional), + roots: Schema.Literals(["true", "false"]) + .pipe( + Schema.decodeTo(Schema.Boolean, { + decode: SchemaGetter.transform((value) => value === "true"), + encode: SchemaGetter.transform((value) => (value ? "true" : "false")), + }), + ) + .pipe(Schema.optional), + start: Schema.NumberFromString.pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), + cursor: Schema.optional(Schema.Never), + }), + Schema.Struct({ + limit: Schema.optional( + Schema.NumberFromString.check( + Schema.isInt(), + Schema.isGreaterThanOrEqualTo(1), + Schema.isLessThanOrEqualTo(200), + ), + ).annotate({ + description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.", + }), + cursor: Schema.String.annotate({ + description: + "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order.", + }), + order: Schema.optional(Schema.Never), + directory: Schema.optional(Schema.Never), + path: Schema.optional(Schema.Never), + workspace: Schema.optional(Schema.Never), + roots: Schema.optional(Schema.Never), + start: Schema.optional(Schema.Never), + search: Schema.optional(Schema.Never), + }), + ]).annotate({ identifier: "V2SessionsQuery" }), + success: Schema.Struct({ + items: Schema.Array(SessionV2.Info), + cursor: Schema.Struct({ + previous: Schema.String.pipe(Schema.optional), + next: Schema.String.pipe(Schema.optional), + }), + }).annotate({ identifier: "V2SessionsResponse" }), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.list", + summary: "List v2 sessions", + description: + "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + }), + ), + ) + .add( + HttpApiEndpoint.post("prompt", "/api/session/:sessionID/prompt", { + params: { sessionID: SessionID }, + payload: Schema.Struct({ + prompt: Prompt, + delivery: SessionV2.Delivery.pipe(Schema.optional), + }), + success: SessionMessage.Message, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.prompt", + summary: "Send v2 message", + description: "Create a v2 session message and queue it for the agent loop.", + }), + ), + ) + .add( + HttpApiEndpoint.post("compact", "/api/session/:sessionID/compact", { + params: { sessionID: SessionID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.compact", + summary: "Compact v2 session", + description: "Compact a v2 session conversation.", + }), + ), + ) + .add( + HttpApiEndpoint.post("wait", "/api/session/:sessionID/wait", { + params: { sessionID: SessionID }, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.wait", + summary: "Wait for v2 session", + description: "Wait for a v2 session agent loop to become idle.", + }), + ), + ) + .add( + HttpApiEndpoint.get("context", "/api/session/:sessionID/context", { + params: { sessionID: SessionID }, + success: Schema.Array(SessionMessage.Message), + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context", + summary: "Get v2 session context", + description: "Retrieve the active context messages for a v2 session (all messages after the last compaction).", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "v2", + description: "Experimental v2 routes.", + }), + ) + .middleware(Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index f9be57f4fd..f80869b64d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,6 +1,7 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" import { EffectBridge } from "@/effect/bridge" +import { Bus } from "@/bus" import { Installation } from "@/installation" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" import { InstallationVersion } from "@opencode-ai/core/installation/version" @@ -43,11 +44,11 @@ function eventResponse() { }) const heartbeat = Stream.tick("10 seconds").pipe( Stream.drop(1), - Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })), + Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })), ) return HttpServerResponse.stream( - Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe( + Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts new file mode 100644 index 0000000000..55cb534581 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -0,0 +1,6 @@ +import { SessionV2 } from "@/v2/session" +import { Layer } from "effect" +import { messageHandlers } from "./v2/message" +import { sessionHandlers } from "./v2/session" + +export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers).pipe(Layer.provide(SessionV2.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts new file mode 100644 index 0000000000..3485d80fd6 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -0,0 +1,60 @@ +import { SessionMessage } from "@/v2/session-message" +import { SessionV2 } from "@/v2/session" +import { Effect, Schema } from "effect" +import * as DateTime from "effect/DateTime" +import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +const DefaultMessagesLimit = 50 + +const Cursor = Schema.Struct({ + id: SessionMessage.ID, + time: Schema.Finite, + order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), + direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), +}) + +const decodeCursor = Schema.decodeUnknownSync(Cursor) + +const cursor = { + encode(message: SessionMessage.Message, order: "asc" | "desc", direction: "previous" | "next") { + return Buffer.from( + JSON.stringify({ id: message.id, time: DateTime.toEpochMillis(message.time.created), order, direction }), + ).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} + +export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers.handle( + "messages", + Effect.fn(function* (ctx) { + const decoded = yield* Effect.try({ + try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), + catch: () => new HttpApiError.BadRequest({}), + }) + const order = decoded?.order ?? ctx.query.order ?? "desc" + const messages = yield* session.messages({ + sessionID: ctx.params.sessionID, + limit: ctx.query.limit ?? DefaultMessagesLimit, + order, + cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, + }) + const first = messages[0] + const last = messages.at(-1) + return { + items: messages, + cursor: { + previous: first ? cursor.encode(first, order, "previous") : undefined, + next: last ? cursor.encode(last, order, "next") : undefined, + }, + } + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts new file mode 100644 index 0000000000..558e34dd18 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -0,0 +1,115 @@ +import { WorkspaceID } from "@/control-plane/schema" +import { SessionV2 } from "@/v2/session" +import { Effect, Schema } from "effect" +import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" +import { InstanceHttpApi } from "../../api" + +const DefaultSessionsLimit = 50 + +const SessionCursor = Schema.Struct({ + id: SessionV2.Info.fields.id, + time: Schema.Finite, + order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]), + direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]), + directory: Schema.String.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + workspaceID: WorkspaceID.pipe(Schema.optional), + roots: Schema.Boolean.pipe(Schema.optional), + start: Schema.Finite.pipe(Schema.optional), + search: Schema.String.pipe(Schema.optional), +}) +type SessionCursor = typeof SessionCursor.Type + +const decodeCursor = Schema.decodeUnknownSync(SessionCursor) + +const sessionCursor = { + encode( + session: SessionV2.Info, + order: "asc" | "desc", + direction: "previous" | "next", + filters: Pick, + ) { + return Buffer.from( + JSON.stringify({ id: session.id, time: session.time.created, order, direction, ...filters }), + ).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} + +export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) => + Effect.gen(function* () { + const session = yield* SessionV2.Service + + return handlers + .handle( + "sessions", + Effect.fn(function* (ctx) { + const decoded = yield* Effect.try({ + try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), + catch: () => new HttpApiError.BadRequest({}), + }) + const order = decoded?.order ?? ctx.query.order ?? "desc" + const filters = decoded ?? { + directory: ctx.query.directory, + path: ctx.query.path, + workspaceID: ctx.query.workspace ? WorkspaceID.make(ctx.query.workspace) : undefined, + roots: ctx.query.roots, + start: ctx.query.start, + search: ctx.query.search, + } + const sessions = yield* session.list({ + limit: ctx.query.limit ?? DefaultSessionsLimit, + order, + directory: filters.directory, + path: filters.path, + workspaceID: filters.workspaceID, + roots: filters.roots, + start: filters.start, + search: filters.search, + cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined, + }) + const first = sessions[0] + const last = sessions.at(-1) + return { + items: sessions, + cursor: { + previous: first ? sessionCursor.encode(first, order, "previous", filters) : undefined, + next: last ? sessionCursor.encode(last, order, "next", filters) : undefined, + }, + } + }), + ) + .handle( + "prompt", + Effect.fn(function* (ctx) { + return yield* session.prompt({ + sessionID: ctx.params.sessionID, + prompt: ctx.payload.prompt, + delivery: ctx.payload.delivery ?? SessionV2.DefaultDelivery, + }) + }), + ) + .handle( + "compact", + Effect.fn(function* (ctx) { + yield* session.compact(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "wait", + Effect.fn(function* (ctx) { + yield* session.wait(ctx.params.sessionID) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "context", + Effect.fn(function* (ctx) { + return yield* session.context(ctx.params.sessionID) + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 0b4bc252c3..e53eca3eff 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -64,6 +64,7 @@ import { questionHandlers } from "./handlers/question" import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" +import { v2Handlers } from "./handlers/v2" import { workspaceHandlers } from "./handlers/workspace" import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context" import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" @@ -115,6 +116,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( providerHandlers, sessionHandlers, syncHandlers, + v2Handlers, tuiHandlers, workspaceHandlers, ]), diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index f0da2f3d85..3f9f3f6607 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -1,7 +1,8 @@ import { describeRoute, resolver, validator } from "hono-openapi" import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" -import { Effect } from "effect" +import { Context, Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" import z from "zod" import { Format } from "@/format" import { TuiRoutes } from "./tui" @@ -25,10 +26,135 @@ import { ExperimentalRoutes } from "./experimental" import { ProviderRoutes } from "./provider" import { EventRoutes } from "./event" import { SyncRoutes } from "./sync" +import { InstanceMiddleware } from "./middleware" import { jsonRequest } from "./trace" +import { ExperimentalHttpApiServer } from "./httpapi/server" +import { EventPaths } from "./httpapi/event" +import { ExperimentalPaths } from "./httpapi/groups/experimental" +import { FilePaths } from "./httpapi/groups/file" +import { InstancePaths } from "./httpapi/groups/instance" +import { McpPaths } from "./httpapi/groups/mcp" +import { PtyPaths } from "./httpapi/groups/pty" +import { SessionPaths } from "./httpapi/groups/session" +import { SyncPaths } from "./httpapi/groups/sync" +import { TuiPaths } from "./httpapi/groups/tui" +import { WorkspacePaths } from "./httpapi/groups/workspace" export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { const app = new Hono() + const handler = ExperimentalHttpApiServer.webHandler().handler + const context = Context.empty() as Context.Context + + app.all("/api/*", (c) => handler(c.req.raw, context)) + + if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { + app.get(EventPaths.event, (c) => handler(c.req.raw, context)) + app.get("/question", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context)) + app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) + app.get("/permission", (c) => handler(c.req.raw, context)) + app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) + app.get("/config", (c) => handler(c.req.raw, context)) + app.patch("/config", (c) => handler(c.req.raw, context)) + app.get("/config/providers", (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context)) + app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context)) + app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context)) + app.get("/provider", (c) => handler(c.req.raw, context)) + app.get("/provider/auth", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context)) + app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context)) + app.get("/project", (c) => handler(c.req.raw, context)) + app.get("/project/current", (c) => handler(c.req.raw, context)) + app.post("/project/git/init", (c) => handler(c.req.raw, context)) + app.patch("/project/:projectID", (c) => handler(c.req.raw, context)) + app.get(FilePaths.findText, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findFile, (c) => handler(c.req.raw, context)) + app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context)) + app.get(FilePaths.list, (c) => handler(c.req.raw, context)) + app.get(FilePaths.content, (c) => handler(c.req.raw, context)) + app.get(FilePaths.status, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.path, (c) => handler(c.req.raw, context)) + app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.command, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.agent, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.skill, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context)) + app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context)) + app.get(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.status, (c) => handler(c.req.raw, context)) + app.post(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context)) + app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context)) + app.delete(McpPaths.auth, (c) => handler(c.req.raw, context)) + app.post(McpPaths.connect, (c) => handler(c.req.raw, context)) + app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.start, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.replay, (c) => handler(c.req.raw, context)) + app.post(SyncPaths.history, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.list, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.create, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) + app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) + app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) + app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.get, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.children, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.todo, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.diff, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.messages, (c) => handler(c.req.raw, context)) + app.get(SessionPaths.message, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.create, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.update, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.init, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.fork, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.abort, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.share, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.command, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.shell, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.revert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context)) + app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context)) + app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.publish, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context)) + app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context)) + app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.adapters, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) + app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) + app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context)) + } return app .route("/project", ProjectRoutes()) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index aaee2be2fe..067d43da2e 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -14,10 +14,13 @@ import { Config } from "@/config/config" import { NotFoundError } from "@/storage/storage" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Layer, Context, Schema } from "effect" +import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" import { makeRuntime } from "@/effect/run-service" import { fn } from "@/util/fn" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" const log = Log.create({ service: "session.compaction" }) @@ -556,7 +559,21 @@ export const layer: Layer.Layer< } if (processor.message.error) return "stop" - if (result === "continue") yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + if (result === "continue") { + const summary = summaryText( + (yield* session.messages({ sessionID: input.sessionID })).find((item) => item.info.id === msg.id) ?? { + info: msg, + parts: [], + }, + ) + EventV2.run(SessionEvent.Compaction.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + text: summary ?? "", + include: selected.tail_start_id, + }) + yield* bus.publish(Event.Compacted, { sessionID: input.sessionID }) + } return result }) @@ -583,6 +600,11 @@ export const layer: Layer.Layer< auto: input.auto, overflow: input.overflow, }) + EventV2.run(SessionEvent.Compaction.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + reason: input.auto ? "auto" : "manual", + }) }) return Service.of({ diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b475ec1c59..1a32a656d1 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -20,6 +20,9 @@ import { Question } from "@/question" import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" +import * as DateTime from "effect/DateTime" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) @@ -221,6 +224,12 @@ export const layer: Layer.Layer< case "reasoning-start": if (value.id in ctx.reasoningMap) return + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Reasoning.Started.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + timestamp: DateTime.makeUnsafe(Date.now()), + }) ctx.reasoningMap[value.id] = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -248,6 +257,13 @@ export const layer: Layer.Layer< case "reasoning-end": if (!(value.id in ctx.reasoningMap)) return + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Reasoning.Ended.Sync, { + sessionID: ctx.sessionID, + reasoningID: value.id, + text: ctx.reasoningMap[value.id].text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) // oxlint-disable-next-line no-self-assign -- reactivity trigger ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } @@ -260,6 +276,13 @@ export const layer: Layer.Layer< if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Input.Started.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + name: value.toolName, + timestamp: DateTime.makeUnsafe(Date.now()), + }) const part = yield* session.updatePart({ id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -281,13 +304,34 @@ export const layer: Layer.Layer< case "tool-input-delta": return - case "tool-input-end": + case "tool-input-end": { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Input.Ended.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + text: "", + timestamp: DateTime.makeUnsafe(Date.now()), + }) return + } case "tool-call": { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Called.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + tool: value.toolName, + input: value.input, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, @@ -331,11 +375,48 @@ export const layer: Layer.Layer< } case "tool-result": { + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Success.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + structured: value.output.metadata, + content: [ + { + type: "text", + text: value.output.output, + }, + ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({ + type: "file", + uri: item.url, + mime: item.mime, + name: item.filename, + })) ?? []), + ], + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* completeToolCall(value.toolCallId, value.output) return } case "tool-error": { + const toolCall = yield* readToolCall(value.toolCallId) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Tool.Error.Sync, { + sessionID: ctx.sessionID, + callID: value.toolCallId, + error: { + type: "unknown", + message: errorMessage(value.error), + }, + provider: { + executed: toolCall?.part.metadata?.providerExecuted === true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) yield* failToolCall(value.toolCallId, value.error) return } @@ -345,6 +426,20 @@ export const layer: Layer.Layer< case "start-step": if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Started.Sync, { + sessionID: ctx.sessionID, + agent: input.assistantMessage.agent, + model: { + id: ctx.model.id, + providerID: ctx.model.providerID, + variant: input.assistantMessage.variant, + }, + snapshot: ctx.snapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } yield* session.updatePart({ id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -355,18 +450,30 @@ export const layer: Layer.Layer< return case "finish-step": { + const completedSnapshot = yield* snapshot.track() const usage = Session.getUsage({ model: ctx.model, usage: value.usage, metadata: value.providerMetadata, }) + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Ended.Sync, { + sessionID: ctx.sessionID, + finish: value.finishReason, + cost: usage.cost, + tokens: usage.tokens, + snapshot: completedSnapshot, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.assistantMessage.finish = value.finishReason ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens yield* session.updatePart({ id: PartID.ascending(), reason: value.finishReason, - snapshot: yield* snapshot.track(), + snapshot: completedSnapshot, messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, type: "step-finish", @@ -404,6 +511,13 @@ export const layer: Layer.Layer< } case "text-start": + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Text.Started.Sync, { + sessionID: ctx.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.currentText = { id: PartID.ascending(), messageID: ctx.assistantMessage.id, @@ -442,6 +556,14 @@ export const layer: Layer.Layer< }, { text: ctx.currentText.text }, )).text + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Text.Ended.Sync, { + sessionID: ctx.sessionID, + text: ctx.currentText.text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } { const end = Date.now() ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end } @@ -568,13 +690,24 @@ export const layer: Layer.Layer< Effect.retry( SessionRetry.policy({ parse, - set: (info) => - status.set(ctx.sessionID, { + set: (info) => { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Retried.Sync, { + sessionID: ctx.sessionID, + attempt: info.attempt, + error: { + message: info.message, + isRetryable: true, + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + return status.set(ctx.sessionID, { type: "retry", attempt: info.attempt, message: info.message, next: info.next, - }), + }) + }, }), ), Effect.catch(halt), diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts new file mode 100644 index 0000000000..951e3e874f --- /dev/null +++ b/packages/opencode/src/session/projectors-next.ts @@ -0,0 +1,204 @@ +import { and, desc, eq } from "@/storage/db" +import type { Database } from "@/storage/db" +import { SessionMessage } from "@/v2/session-message" +import { SessionMessageUpdater } from "@/v2/session-message-updater" +import { SessionEvent } from "@/v2/session-event" +import * as DateTime from "effect/DateTime" +import { SyncEvent } from "@/sync" +import { SessionMessageTable, SessionTable } from "./session.sql" +import type { SessionID } from "./schema" +import { Schema } from "effect" + +const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) +type SessionMessageData = NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]> + +function encodeDateTimes(value: unknown): unknown { + if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value) + if (Array.isArray(value)) return value.map(encodeDateTimes) + if (typeof value === "object" && value !== null) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeDateTimes(item)])) + } + return value +} + +function encodeMessageData(value: unknown): SessionMessageData { + return encodeDateTimes(value) as SessionMessageData +} + +function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdater.Adapter { + return { + getCurrentAssistant() { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed) + }, + getCurrentCompaction() { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Compaction => message.type === "compaction") + }, + getCurrentShell(callID) { + return db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "shell"))) + .orderBy(desc(SessionMessageTable.id)) + .all() + .map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type })) + .find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID) + }, + updateAssistant(assistant) { + const { id, type, ...data } = assistant + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + updateCompaction(compaction) { + const { id, type, ...data } = compaction + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + updateShell(shell) { + const { id, type, ...data } = shell + db.update(SessionMessageTable) + .set({ data: encodeMessageData(data) }) + .where( + and( + eq(SessionMessageTable.id, id), + eq(SessionMessageTable.session_id, sessionID), + eq(SessionMessageTable.type, type), + ), + ) + .run() + }, + appendMessage(message) { + const { id, type, ...data } = message + db.insert(SessionMessageTable) + .values([ + { + id, + session_id: sessionID, + type, + time_created: DateTime.toEpochMillis(message.time.created), + data: encodeMessageData(data), + }, + ]) + .run() + }, + finish() {}, + } +} + +function update(db: Database.TxOrDb, event: SessionEvent.Event) { + SessionMessageUpdater.update(sqlite(db, event.data.sessionID), event) +} + +export default [ + SyncEvent.project(SessionEvent.AgentSwitched.Sync, (db, data, event) => { + db.update(SessionTable) + .set({ + agent: data.agent, + time_updated: DateTime.toEpochMillis(data.timestamp), + }) + .where(eq(SessionTable.id, data.sessionID)) + .run() + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.agent.switched", data }) + }), + SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => { + db.update(SessionTable) + .set({ + model: { + id: data.id, + providerID: data.providerID, + variant: data.variant, + }, + time_updated: DateTime.toEpochMillis(data.timestamp), + }) + .where(eq(SessionTable.id, data.sessionID)) + .run() + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.model.switched", data }) + }), + SyncEvent.project(SessionEvent.Prompted.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data }) + }), + SyncEvent.project(SessionEvent.Synthetic.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data }) + }), + SyncEvent.project(SessionEvent.Shell.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data }) + }), + SyncEvent.project(SessionEvent.Shell.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data }) + }), + SyncEvent.project(SessionEvent.Step.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data }) + }), + SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data }) + }), + SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data }) + }), + SyncEvent.project(SessionEvent.Text.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Text.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data }) + }), + SyncEvent.project(SessionEvent.Tool.Input.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Tool.Input.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data }) + }), + SyncEvent.project(SessionEvent.Tool.Called.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data }) + }), + SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data }) + }), + SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data }) + }), + SyncEvent.project(SessionEvent.Reasoning.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Reasoning.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data }) + }), + SyncEvent.project(SessionEvent.Retried.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data }) + }), + SyncEvent.project(SessionEvent.Compaction.Started.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data }) + }), + SyncEvent.project(SessionEvent.Compaction.Delta.Sync, () => {}), + SyncEvent.project(SessionEvent.Compaction.Ended.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.ended", data }) + }), +] diff --git a/packages/opencode/src/session/projectors.ts b/packages/opencode/src/session/projectors.ts index a3832ebe65..9819ad810f 100644 --- a/packages/opencode/src/session/projectors.ts +++ b/packages/opencode/src/session/projectors.ts @@ -5,7 +5,8 @@ import { SyncEvent } from "@/sync" import * as Session from "./session" import { MessageV2 } from "./message-v2" import { SessionTable, MessageTable, PartTable } from "./session.sql" -import * as Log from "@opencode-ai/core/util/log" +import { Log } from "@opencode-ai/core/util/log" +import nextProjectors from "./projectors-next" const log = Log.create({ service: "session.projector" }) @@ -136,4 +137,6 @@ export default [ log.warn("ignored late part update", { partID: id, messageID, sessionID }) } }), + + ...nextProjectors, ] diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9f1420388e..0590fc3827 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -54,6 +54,13 @@ import { InstanceState } from "@/effect/instance-state" import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" +import { EventV2 } from "@/v2/event" +import { SessionEvent } from "@/v2/session-event" +import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" +import * as DateTime from "effect/DateTime" +import { eq } from "@/storage/db" +import * as Database from "@/storage/db" +import { SessionTable } from "./session.sql" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -785,6 +792,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the providerID: model.providerID, } yield* sessions.updateMessage(msg) + const callID = ulid() + const started = Date.now() const part: MessageV2.ToolPart = { type: "tool", id: PartID.ascending(), @@ -794,11 +803,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the callID: ulid(), state: { status: "running", - time: { start: Date.now() }, + time: { start: started }, input: { command: input.command }, }, } yield* sessions.updatePart(part) + EventV2.run(SessionEvent.Shell.Started.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(started), + callID, + command: input.command, + }) return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -813,14 +828,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the if (aborted) { output += "\n\n" + ["", "User aborted the command", ""].join("\n") } + const completed = Date.now() + EventV2.run(SessionEvent.Shell.Ended.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(completed), + callID: part.callID, + output, + }) if (!msg.time.completed) { - msg.time.completed = Date.now() + msg.time.completed = completed yield* sessions.updateMessage(msg) } if (part.state.status === "running") { part.state = { status: "completed", - time: { ...part.state.time, end: Date.now() }, + time: { ...part.state.time, end: completed }, input: part.state.input, title: "", metadata: { output, description: "" }, @@ -934,6 +956,34 @@ NOTE: At any point in time through this workflow you should feel free to ask the format: input.format, } + const current = Database.use((db) => + db + .select({ agent: SessionTable.agent, model: SessionTable.model }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + if (current?.agent !== info.agent) { + EventV2.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + agent: info.agent, + }) + } + if ( + current?.model?.providerID !== info.model.providerID || + current.model.id !== info.model.modelID || + current.model.variant !== info.model.variant + ) { + EventV2.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + id: info.model.modelID, + providerID: info.model.providerID, + variant: info.model.variant, + }) + } + yield* Effect.addFinalizer(() => instruction.clear(info.id)) type Draft = T extends MessageV2.Part ? Omit & { id?: string } : never @@ -1250,6 +1300,69 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* sessions.updateMessage(info) for (const part of parts) yield* sessions.updatePart(part) + const nextPrompt = parts.reduce( + (result, part) => { + if (part.type === "text") { + if (part.synthetic) result.synthetic.push(part.text) + else result.text.push(part.text) + } + if (part.type === "file") { + result.files.push( + new FileAttachment({ + uri: part.url, + mime: part.mime, + name: part.filename, + source: part.source + ? new Source({ + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + }) + : undefined, + }), + ) + } + if (part.type === "agent") { + result.agents.push( + new AgentAttachment({ + name: part.name, + source: part.source + ? new Source({ + start: part.source.start, + end: part.source.end, + text: part.source.value, + }) + : undefined, + }), + ) + } + return result + }, + { + text: [] as string[], + files: [] as FileAttachment[], + agents: [] as AgentAttachment[], + synthetic: [] as string[], + }, + ) + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Prompted.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + prompt: { + text: nextPrompt.text.join("\n"), + files: nextPrompt.files, + agents: nextPrompt.agents, + }, + }) + for (const text of nextPrompt.synthetic) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Synthetic.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(info.time.created), + text, + }) + } return { info, parts } }, Effect.scoped) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 863fb21d65..421fa68694 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,7 +1,7 @@ import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" -import type { SessionEntry } from "../v2/session-entry" +import type { SessionMessage } from "../v2/session-message" import type { Snapshot } from "../snapshot" import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" @@ -11,6 +11,7 @@ import { Timestamps } from "../storage/schema.sql" type PartData = Omit type InfoData = Omit +type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> export const SessionTable = sqliteTable( "session", @@ -34,6 +35,12 @@ export const SessionTable = sqliteTable( summary_diffs: text({ mode: "json" }).$type(), revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), + agent: text(), + model: text({ mode: "json" }).$type<{ + id: string + providerID: string + variant?: string + }>(), ...Timestamps, time_compacting: integer(), time_archived: integer(), @@ -96,22 +103,22 @@ export const TodoTable = sqliteTable( ], ) -export const SessionEntryTable = sqliteTable( - "session_entry", +export const SessionMessageTable = sqliteTable( + "session_message", { - id: text().$type().primaryKey(), + id: text().$type().primaryKey(), session_id: text() .$type() .notNull() .references(() => SessionTable.id, { onDelete: "cascade" }), - type: text().$type().notNull(), + type: text().$type().notNull(), ...Timestamps, - data: text({ mode: "json" }).notNull().$type>(), + data: text({ mode: "json" }).notNull().$type(), }, (table) => [ - index("session_entry_session_idx").on(table.session_id), - index("session_entry_session_type_idx").on(table.session_id, table.type), - index("session_entry_time_created_idx").on(table.time_created), + index("session_message_session_idx").on(table.session_id), + index("session_message_session_type_idx").on(table.session_id, table.type), + index("session_message_time_created_idx").on(table.time_created), ], ) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index e1d0c527aa..fedfa8996e 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -32,6 +32,7 @@ import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" +import { ModelID, ProviderID } from "@/provider/schema" import type { Provider } from "@/provider/provider" import { Permission } from "@/permission" @@ -78,6 +79,10 @@ export function fromRow(row: SessionRow): Info { path: row.path ?? undefined, parentID: row.parent_id ?? undefined, title: row.title, + agent: row.agent ?? undefined, + model: row.model + ? { id: ModelID.make(row.model.id), providerID: ProviderID.make(row.model.providerID), variant: row.model.variant } + : undefined, version: row.version, summary, share, @@ -102,6 +107,8 @@ export function toRow(info: Info) { directory: info.directory, path: info.path, title: info.title, + agent: info.agent, + model: info.model, version: info.version, share_url: info.share?.url, summary_additions: info.summary?.additions, @@ -160,6 +167,12 @@ const Revert = Schema.Struct({ diff: optionalOmitUndefined(Schema.String), }) +const Model = Schema.Struct({ + id: ModelID, + providerID: ProviderID, + variant: optionalOmitUndefined(Schema.String), +}) + export const Info = Schema.Struct({ id: SessionID, slug: Schema.String, @@ -171,6 +184,8 @@ export const Info = Schema.Struct({ summary: optionalOmitUndefined(Summary), share: optionalOmitUndefined(Share), title: Schema.String, + agent: optionalOmitUndefined(Schema.String), + model: optionalOmitUndefined(Model), version: Schema.String, time: Time, permission: optionalOmitUndefined(Permission.Ruleset), @@ -201,6 +216,8 @@ export const CreateInput = Schema.optional( Schema.Struct({ parentID: Schema.optional(SessionID), title: Schema.optional(Schema.String), + agent: Schema.optional(Schema.String), + model: Schema.optional(Model), permission: Schema.optional(Permission.Ruleset), workspaceID: Schema.optional(WorkspaceID), }), @@ -272,6 +289,8 @@ const UpdatedInfo = Schema.Struct({ summary: Schema.optional(Schema.NullOr(Summary)), share: Schema.optional(UpdatedShare), title: Schema.optional(Schema.NullOr(Schema.String)), + agent: Schema.optional(Schema.NullOr(Schema.String)), + model: Schema.optional(Schema.NullOr(Model)), version: Schema.optional(Schema.NullOr(Schema.String)), time: Schema.optional(UpdatedTime), permission: Schema.optional(Schema.NullOr(Permission.Ruleset)), @@ -404,6 +423,8 @@ export interface Interface { readonly create: (input?: { parentID?: SessionID title?: string + agent?: string + model?: Schema.Schema.Type permission?: Permission.Ruleset workspaceID?: WorkspaceID }) => Effect.Effect @@ -464,6 +485,8 @@ export const layer: Layer.Layer parentID?: SessionID workspaceID?: WorkspaceID directory: string @@ -481,6 +504,8 @@ export const layer: Layer.Layer permission?: Permission.Ruleset workspaceID?: WorkspaceID }) { @@ -601,6 +628,8 @@ export const layer: Layer.Layer = EffectSchema.Schem export type SerializedEvent = Event & { type: string } -type ProjectorFunc = (db: Database.TxOrDb, data: unknown) => void +type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise type PublishContext = { instance?: InstanceContext @@ -255,7 +255,7 @@ export function define< export function project( def: Def, - func: (db: Database.TxOrDb, data: Event["data"]) => void, + func: (db: Database.TxOrDb, data: Event["data"], event: Event) => void, ): [Definition, ProjectorFunc] { return [def, func as ProjectorFunc] } @@ -277,7 +277,7 @@ function process( // idempotent: need to ignore any events already logged Database.transaction((tx) => { - projector(tx, event.data) + projector(tx, event.data, event) if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { tx.insert(EventSequenceTable) @@ -308,7 +308,7 @@ function process( } const result = convertEvent(def.type, event.data) - const publish = (data: unknown) => ProjectBus.publish(def, data as Properties) + const publish = (data: unknown) => ProjectBus.publish(def, data as Properties, { id: event.id }) if (result instanceof Promise) { void result.then(publish) } else { diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 332a5c76eb..1c88712d7d 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -90,7 +90,7 @@ function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { // Schema.withDecodingDefault also attaches encoding, but we want `.default(v)` // on the inner Zod rather than a transform wrapper — so optional ASTs whose // encoding resolves a default from Option.none() route through body()/opt(). - const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" + const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0) const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base diff --git a/packages/opencode/src/v2/event.ts b/packages/opencode/src/v2/event.ts new file mode 100644 index 0000000000..fde8d4326f --- /dev/null +++ b/packages/opencode/src/v2/event.ts @@ -0,0 +1,53 @@ +import { Identifier } from "@/id/id" +import { SyncEvent } from "@/sync" +import { withStatics } from "@/util/schema" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Schema from "effect/Schema" + +export const ID = Schema.String.pipe( + Schema.brand("Event.ID"), + withStatics((s) => ({ + create: () => s.make(Identifier.create("evt", "ascending")), + })), +) +export type ID = Schema.Schema.Type + +export function define(input: { + type: Type + schema: Fields + aggregate: string + version?: number +}) { + const Payload = Schema.Struct({ + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + type: Schema.Literal(input.type), + data: Schema.Struct(input.schema), + }).annotate({ + identifier: input.type, + }) + + const Sync = SyncEvent.define({ + type: input.type, + version: input.version ?? 1, + aggregate: input.aggregate, + schema: Payload.fields.data, + }) + + return Object.assign(Payload, { + Sync, + version: input.version, + aggregate: input.aggregate, + }) +} + +export function run( + def: Def, + data: SyncEvent.Event["data"], + options?: { publish?: boolean }, +) { + if (!Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM) return + SyncEvent.run(def, data, options) +} + +export * as EventV2 from "./event" diff --git a/packages/opencode/src/v2/schema.ts b/packages/opencode/src/v2/schema.ts new file mode 100644 index 0000000000..44587b838a --- /dev/null +++ b/packages/opencode/src/v2/schema.ts @@ -0,0 +1,10 @@ +import { DateTime, Schema, SchemaGetter } from "effect" + +export const DateTimeUtcFromMillis = Schema.Finite.pipe( + Schema.decodeTo(Schema.DateTimeUtc, { + decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)), + encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)), + }), +) + +export * as V2Schema from "./schema" diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts deleted file mode 100644 index 3fe4266c04..0000000000 --- a/packages/opencode/src/v2/session-entry-stepper.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { produce, type WritableDraft } from "immer" -import { SessionEvent } from "./session-event" -import { SessionEntry } from "./session-entry" - -export type MemoryState = { - entries: SessionEntry.Entry[] - pending: SessionEntry.Entry[] -} - -export interface Adapter { - readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined - readonly updateAssistant: (assistant: SessionEntry.Assistant) => void - readonly appendEntry: (entry: SessionEntry.Entry) => void - readonly appendPending: (entry: SessionEntry.Entry) => void - readonly finish: () => Result -} - -export function memory(state: MemoryState): Adapter { - const activeAssistantIndex = () => - state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) - - return { - getCurrentAssistant() { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = state.entries[index] - return assistant?.type === "assistant" ? assistant : undefined - }, - updateAssistant(assistant) { - const index = activeAssistantIndex() - if (index < 0) return - const current = state.entries[index] - if (current?.type !== "assistant") return - state.entries[index] = assistant - }, - appendEntry(entry) { - state.entries.push(entry) - }, - appendPending(entry) { - state.pending.push(entry) - }, - finish() { - return state - }, - } -} - -export function stepWith(adapter: Adapter, event: SessionEvent.Event): Result { - const currentAssistant = adapter.getCurrentAssistant() - type DraftAssistant = WritableDraft - type DraftTool = WritableDraft - type DraftText = WritableDraft - type DraftReasoning = WritableDraft - - const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => - assistant?.content.findLast( - (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID), - ) - - const latestText = (assistant: DraftAssistant | undefined) => - assistant?.content.findLast((item): item is DraftText => item.type === "text") - - const latestReasoning = (assistant: DraftAssistant | undefined) => - assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning") - - SessionEvent.Event.match(event, { - prompt: (event) => { - const entry = SessionEntry.User.fromEvent(event) - if (currentAssistant) { - adapter.appendPending(entry) - return - } - adapter.appendEntry(entry) - }, - synthetic: (event) => { - adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event)) - }, - "step.started": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp - }), - ) - } - adapter.appendEntry(SessionEntry.Assistant.fromEvent(event)) - }, - "step.ended": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.time.completed = event.timestamp - draft.cost = event.cost - draft.tokens = event.tokens - }), - ) - } - }, - "text.started": () => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "text", - text: "", - }) - }), - ) - } - }, - "text.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestText(draft) - if (match) match.text += event.delta - }), - ) - } - }, - "text.ended": () => {}, - "tool.input.started": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "tool", - callID: event.callID, - name: event.name, - time: { - created: event.timestamp, - }, - state: { - status: "pending", - input: "", - }, - }) - }), - ) - } - }, - "tool.input.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) - if (match && match.state.status === "pending") match.state.input += event.delta - }), - ) - } - }, - "tool.input.ended": () => {}, - "tool.called": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match) { - match.time.ran = event.timestamp - match.state = { - status: "running", - input: event.input, - } - } - }), - ) - } - }, - "tool.success": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "completed", - input: match.state.input, - output: event.output ?? "", - title: event.title, - metadata: event.metadata ?? {}, - attachments: [...(event.attachments ?? [])], - } - } - }), - ) - } - }, - "tool.error": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestTool(draft, event.callID) - if (match && match.state.status === "running") { - match.state = { - status: "error", - error: event.error, - input: match.state.input, - metadata: event.metadata ?? {}, - } - } - }), - ) - } - }, - "reasoning.started": () => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.content.push({ - type: "reasoning", - text: "", - }) - }), - ) - } - }, - "reasoning.delta": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) - if (match) match.text += event.delta - }), - ) - } - }, - "reasoning.ended": (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - const match = latestReasoning(draft) - if (match) match.text = event.text - }), - ) - } - }, - retried: (event) => { - if (currentAssistant) { - adapter.updateAssistant( - produce(currentAssistant, (draft) => { - draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)] - }), - ) - } - }, - compacted: (event) => { - adapter.appendEntry(SessionEntry.Compaction.fromEvent(event)) - }, - }) - - return adapter.finish() -} - -export function step(old: MemoryState, event: SessionEvent.Event): MemoryState { - return produce(old, (draft) => { - stepWith(memory(draft as MemoryState), event) - }) -} - -export * as SessionEntryStepper from "./session-entry-stepper" diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts deleted file mode 100644 index 66576a688e..0000000000 --- a/packages/opencode/src/v2/session-entry.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { Schema } from "effect" -import { NonNegativeInt } from "@/util/schema" -import { SessionEvent } from "./session-event" - -export const ID = SessionEvent.ID -export type ID = Schema.Schema.Type - -const Base = { - id: SessionEvent.ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -} - -export class User extends Schema.Class("Session.Entry.User")({ - ...Base, - text: SessionEvent.Prompt.fields.text, - files: SessionEvent.Prompt.fields.files, - agents: SessionEvent.Prompt.fields.agents, - type: Schema.Literal("user"), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -}) { - static fromEvent(event: SessionEvent.Prompt) { - return new User({ - id: event.id, - type: "user", - metadata: event.metadata, - text: event.text, - files: event.files, - agents: event.agents, - time: { created: event.timestamp }, - }) - } -} - -export class Synthetic extends Schema.Class("Session.Entry.Synthetic")({ - ...SessionEvent.Synthetic.fields, - ...Base, - type: Schema.Literal("synthetic"), -}) { - static fromEvent(event: SessionEvent.Synthetic) { - return new Synthetic({ - ...event, - time: { created: event.timestamp }, - }) - } -} - -export class ToolStatePending extends Schema.Class("Session.Entry.ToolState.Pending")({ - status: Schema.Literal("pending"), - input: Schema.String, -}) {} - -export class ToolStateRunning extends Schema.Class("Session.Entry.ToolState.Running")({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Unknown), - title: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) {} - -export class ToolStateCompleted extends Schema.Class("Session.Entry.ToolState.Completed")({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Unknown), - output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown), - attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), -}) {} - -export class ToolStateError extends Schema.Class("Session.Entry.ToolState.Error")({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Unknown), - error: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), -}) {} - -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( - Schema.toTaggedUnion("status"), -) -export type ToolState = Schema.Schema.Type - -export class AssistantTool extends Schema.Class("Session.Entry.Assistant.Tool")({ - type: Schema.Literal("tool"), - callID: Schema.String, - name: Schema.String, - state: ToolState, - time: Schema.Struct({ - created: Schema.DateTimeUtc, - ran: Schema.DateTimeUtc.pipe(Schema.optional), - completed: Schema.DateTimeUtc.pipe(Schema.optional), - pruned: Schema.DateTimeUtc.pipe(Schema.optional), - }), -}) {} - -export class AssistantText extends Schema.Class("Session.Entry.Assistant.Text")({ - type: Schema.Literal("text"), - text: Schema.String, -}) {} - -export class AssistantReasoning extends Schema.Class("Session.Entry.Assistant.Reasoning")({ - type: Schema.Literal("reasoning"), - text: Schema.String, -}) {} - -export class AssistantRetry extends Schema.Class("Session.Entry.Assistant.Retry")({ - attempt: NonNegativeInt, - error: SessionEvent.RetryError, - time: Schema.Struct({ - created: Schema.DateTimeUtc, - }), -}) { - static fromEvent(event: SessionEvent.Retried) { - return new AssistantRetry({ - attempt: event.attempt, - error: event.error, - time: { - created: event.timestamp, - }, - }) - } -} - -export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( - Schema.toTaggedUnion("type"), -) -export type AssistantContent = Schema.Schema.Type - -export class Assistant extends Schema.Class("Session.Entry.Assistant")({ - ...Base, - type: Schema.Literal("assistant"), - content: AssistantContent.pipe(Schema.Array), - retries: AssistantRetry.pipe(Schema.Array, Schema.optional), - cost: Schema.Finite.pipe(Schema.optional), - tokens: Schema.Struct({ - input: NonNegativeInt, - output: NonNegativeInt, - reasoning: NonNegativeInt, - cache: Schema.Struct({ - read: NonNegativeInt, - write: NonNegativeInt, - }), - }).pipe(Schema.optional), - error: Schema.String.pipe(Schema.optional), - time: Schema.Struct({ - created: Schema.DateTimeUtc, - completed: Schema.DateTimeUtc.pipe(Schema.optional), - }), -}) { - static fromEvent(event: SessionEvent.Step.Started) { - return new Assistant({ - id: event.id, - type: "assistant", - time: { - created: event.timestamp, - }, - content: [], - retries: [], - }) - } -} - -export class Compaction extends Schema.Class("Session.Entry.Compaction")({ - ...SessionEvent.Compacted.fields, - type: Schema.Literal("compaction"), - ...Base, -}) { - static fromEvent(event: SessionEvent.Compacted) { - return new Compaction({ - ...event, - type: "compaction", - time: { created: event.timestamp }, - }) - } -} - -export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type")) - -export type Entry = Schema.Schema.Type - -export type Type = Entry["type"] - -/* -export interface Interface { - readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry - readonly fromSession: (sessionID: SessionID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/SessionEntry") {} - -export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const decodeEntry = Schema.decodeUnknownSync(Entry) - - const decode: (typeof Service.Service)["decode"] = (row) => decodeEntry({ ...row, id: row.id, type: row.type }) - - const fromSession = Effect.fn("SessionEntry.fromSession")(function* (sessionID: SessionID) { - return Database.use((db) => - db - .select() - .from(SessionEntryTable) - .where(eq(SessionEntryTable.session_id, sessionID)) - .orderBy(SessionEntryTable.id) - .all() - .map((row) => decode(row)), - ) - }) - - return Service.of({ - decode, - fromSession, - }) - }), -) -*/ - -export * as SessionEntry from "./session-entry" diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index aaf71c8dcc..3af5932f0d 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -1,128 +1,119 @@ -import { Identifier } from "@/id/id" -import { NonNegativeInt, withStatics } from "@/util/schema" -import * as DateTime from "effect/DateTime" +import { SessionID } from "@/session/schema" +import { NonNegativeInt } from "@/util/schema" +import { EventV2 } from "./event" +import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" +export { FileAttachment } +import { ToolOutput } from "./tool-output" +import { ModelID, ProviderID } from "@/provider/schema" +import { V2Schema } from "./schema" -export namespace SessionEvent { - export const ID = Schema.String.pipe( - Schema.brand("Session.Event.ID"), - withStatics((s) => ({ - create: () => s.make(Identifier.create("evt", "ascending")), - })), - ) - export type ID = Schema.Schema.Type - type Stamp = Schema.Schema.Type - type BaseInput = { - id?: ID - metadata?: Record - timestamp?: Stamp - } +export const Source = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, + text: Schema.String, +}).annotate({ + identifier: "session.next.event.source", +}) +export type Source = Schema.Schema.Type - const Base = { - id: ID, - metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), - timestamp: Schema.DateTimeUtc, - } +const Base = { + timestamp: V2Schema.DateTimeUtcFromMillis, + sessionID: SessionID, +} - export class Source extends Schema.Class("Session.Event.Source")({ - start: NonNegativeInt, - end: NonNegativeInt, - text: Schema.String, - }) {} - - export class FileAttachment extends Schema.Class("Session.Event.FileAttachment")({ - uri: Schema.String, - mime: Schema.String, - name: Schema.String.pipe(Schema.optional), - description: Schema.String.pipe(Schema.optional), - source: Source.pipe(Schema.optional), - }) { - static create(input: FileAttachment) { - return new FileAttachment({ - uri: input.uri, - mime: input.mime, - name: input.name, - description: input.description, - source: input.source, - }) - } - } +export const AgentSwitched = EventV2.define({ + type: "session.next.agent.switched", + aggregate: "sessionID", + version: 1, + schema: { + ...Base, + agent: Schema.String, + }, +}) +export type AgentSwitched = Schema.Schema.Type - export class AgentAttachment extends Schema.Class("Session.Event.AgentAttachment")({ - name: Schema.String, - source: Source.pipe(Schema.optional), - }) {} - - export class RetryError extends Schema.Class("Session.Event.Retry.Error")({ - message: Schema.String, - statusCode: NonNegativeInt.pipe(Schema.optional), - isRetryable: Schema.Boolean, - responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - responseBody: Schema.String.pipe(Schema.optional), - metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), - }) {} - - export class Prompt extends Schema.Class("Session.Event.Prompt")({ +export const ModelSwitched = EventV2.define({ + type: "session.next.model.switched", + aggregate: "sessionID", + version: 1, + schema: { ...Base, - type: Schema.Literal("prompt"), - text: Schema.String, - files: Schema.Array(FileAttachment).pipe(Schema.optional), - agents: Schema.Array(AgentAttachment).pipe(Schema.optional), - }) { - static create(input: BaseInput & { text: string; files?: FileAttachment[]; agents?: AgentAttachment[] }) { - return new Prompt({ - id: input.id ?? ID.create(), - type: "prompt", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - files: input.files, - agents: input.agents, - }) - } - } + id: ModelID, + providerID: ProviderID, + variant: Schema.String.pipe(Schema.optional), + }, +}) +export type ModelSwitched = Schema.Schema.Type + +export const Prompted = EventV2.define({ + type: "session.next.prompted", + aggregate: "sessionID", + version: 1, + schema: { + ...Base, + prompt: Prompt, + }, +}) +export type Prompted = Schema.Schema.Type - export class Synthetic extends Schema.Class("Session.Event.Synthetic")({ +export const Synthetic = EventV2.define({ + type: "session.next.synthetic", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("synthetic"), text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Synthetic({ - id: input.id ?? ID.create(), - type: "synthetic", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } + }, +}) +export type Synthetic = Schema.Schema.Type + +export namespace Shell { + export const Started = EventV2.define({ + type: "session.next.shell.started", + aggregate: "sessionID", + schema: { + ...Base, + callID: Schema.String, + command: Schema.String, + }, + }) + export type Started = Schema.Schema.Type + + export const Ended = EventV2.define({ + type: "session.next.shell.ended", + aggregate: "sessionID", + schema: { + ...Base, + callID: Schema.String, + output: Schema.String, + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Step { - export class Started extends Schema.Class("Session.Event.Step.Started")({ +export namespace Step { + export const Started = EventV2.define({ + type: "session.next.step.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("step.started"), + agent: Schema.String, model: Schema.Struct({ id: Schema.String, providerID: Schema.String, variant: Schema.String.pipe(Schema.optional), }), - }) { - static create(input: BaseInput & { model: { id: string; providerID: string; variant?: string } }) { - return new Started({ - id: input.id ?? ID.create(), - type: "step.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - model: input.model, - }) - } - } - - export class Ended extends Schema.Class("Session.Event.Step.Ended")({ + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Started = Schema.Schema.Type + + export const Ended = EventV2.define({ + type: "session.next.step.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("step.ended"), - reason: Schema.String, + finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ input: NonNegativeInt, @@ -133,177 +124,118 @@ export namespace SessionEvent { write: NonNegativeInt, }), }), - }) { - static create(input: BaseInput & { reason: string; cost: number; tokens: Ended["tokens"] }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "step.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - reason: input.reason, - cost: input.cost, - tokens: input.tokens, - }) - } - } - } + snapshot: Schema.String.pipe(Schema.optional), + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Text { - export class Started extends Schema.Class("Session.Event.Text.Started")({ +export namespace Text { + export const Started = EventV2.define({ + type: "session.next.text.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "text.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } - - export class Delta extends Schema.Class("Session.Event.Text.Delta")({ + }, + }) + export type Started = Schema.Schema.Type + + export const Delta = EventV2.define({ + type: "session.next.text.delta", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.delta"), delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "text.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } - - export class Ended extends Schema.Class("Session.Event.Text.Ended")({ + }, + }) + export type Delta = Schema.Schema.Type + + export const Ended = EventV2.define({ + type: "session.next.text.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("text.ended"), text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "text.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Reasoning { - export class Started extends Schema.Class("Session.Event.Reasoning.Started")({ +export namespace Reasoning { + export const Started = EventV2.define({ + type: "session.next.reasoning.started", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.started"), - }) { - static create(input: BaseInput = {}) { - return new Started({ - id: input.id ?? ID.create(), - type: "reasoning.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - }) - } - } - - export class Delta extends Schema.Class("Session.Event.Reasoning.Delta")({ + reasoningID: Schema.String, + }, + }) + export type Started = Schema.Schema.Type + + export const Delta = EventV2.define({ + type: "session.next.reasoning.delta", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.delta"), + reasoningID: Schema.String, delta: Schema.String, - }) { - static create(input: BaseInput & { delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "reasoning.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - delta: input.delta, - }) - } - } - - export class Ended extends Schema.Class("Session.Event.Reasoning.Ended")({ + }, + }) + export type Delta = Schema.Schema.Type + + export const Ended = EventV2.define({ + type: "session.next.reasoning.ended", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("reasoning.ended"), + reasoningID: Schema.String, text: Schema.String, - }) { - static create(input: BaseInput & { text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "reasoning.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - text: input.text, - }) - } - } - } + }, + }) + export type Ended = Schema.Schema.Type +} - export namespace Tool { - export namespace Input { - export class Started extends Schema.Class("Session.Event.Tool.Input.Started")({ +export namespace Tool { + export namespace Input { + export const Started = EventV2.define({ + type: "session.next.tool.input.started", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, name: Schema.String, - type: Schema.Literal("tool.input.started"), - }) { - static create(input: BaseInput & { callID: string; name: string }) { - return new Started({ - id: input.id ?? ID.create(), - type: "tool.input.started", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - name: input.name, - }) - } - } - - export class Delta extends Schema.Class("Session.Event.Tool.Input.Delta")({ + }, + }) + export type Started = Schema.Schema.Type + + export const Delta = EventV2.define({ + type: "session.next.tool.input.delta", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, - type: Schema.Literal("tool.input.delta"), delta: Schema.String, - }) { - static create(input: BaseInput & { callID: string; delta: string }) { - return new Delta({ - id: input.id ?? ID.create(), - type: "tool.input.delta", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - delta: input.delta, - }) - } - } - - export class Ended extends Schema.Class("Session.Event.Tool.Input.Ended")({ + }, + }) + export type Delta = Schema.Schema.Type + + export const Ended = EventV2.define({ + type: "session.next.tool.input.ended", + aggregate: "sessionID", + schema: { ...Base, callID: Schema.String, - type: Schema.Literal("tool.input.ended"), text: Schema.String, - }) { - static create(input: BaseInput & { callID: string; text: string }) { - return new Ended({ - id: input.id ?? ID.create(), - type: "tool.input.ended", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - text: input.text, - }) - } - } - } - - export class Called extends Schema.Class("Session.Event.Tool.Called")({ + }, + }) + export type Ended = Schema.Schema.Type + } + + export const Called = EventV2.define({ + type: "session.next.tool.called", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.called"), callID: Schema.String, tool: Schema.String, input: Schema.Record(Schema.String, Schema.Unknown), @@ -311,148 +243,155 @@ export namespace SessionEvent { executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - tool: string - input: Record - provider: Called["provider"] - }, - ) { - return new Called({ - id: input.id ?? ID.create(), - type: "tool.called", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - tool: input.tool, - input: input.input, - provider: input.provider, - }) - } - } - - export class Success extends Schema.Class("Session.Event.Tool.Success")({ + }, + }) + export type Called = Schema.Schema.Type + + export const Progress = EventV2.define({ + type: "session.next.tool.progress", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.success"), callID: Schema.String, - title: Schema.String, - output: Schema.String.pipe(Schema.optional), - attachments: Schema.Array(FileAttachment).pipe(Schema.optional), + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), + }, + }) + export type Progress = Schema.Schema.Type + + export const Success = EventV2.define({ + type: "session.next.tool.success", + aggregate: "sessionID", + schema: { + ...Base, + callID: Schema.String, + structured: ToolOutput.Structured, + content: Schema.Array(ToolOutput.Content), provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create( - input: BaseInput & { - callID: string - title: string - output?: string - attachments?: FileAttachment[] - provider: Success["provider"] - }, - ) { - return new Success({ - id: input.id ?? ID.create(), - type: "tool.success", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - title: input.title, - output: input.output, - attachments: input.attachments, - provider: input.provider, - }) - } - } - - export class Error extends Schema.Class("Session.Event.Tool.Error")({ + }, + }) + export type Success = Schema.Schema.Type + + export const Error = EventV2.define({ + type: "session.next.tool.error", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("tool.error"), callID: Schema.String, - error: Schema.String, + error: Schema.Struct({ + type: Schema.String, + message: Schema.String, + }), provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), - }) { - static create(input: BaseInput & { callID: string; error: string; provider: Error["provider"] }) { - return new Error({ - id: input.id ?? ID.create(), - type: "tool.error", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - callID: input.callID, - error: input.error, - provider: input.provider, - }) - } - } - } + }, + }) + export type Error = Schema.Schema.Type +} + +export const RetryError = Schema.Struct({ + message: Schema.String, + statusCode: NonNegativeInt.pipe(Schema.optional), + isRetryable: Schema.Boolean, + responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), + responseBody: Schema.String.pipe(Schema.optional), + metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), +}).annotate({ + identifier: "session.next.retry_error", +}) +export type RetryError = Schema.Schema.Type - export class Retried extends Schema.Class("Session.Event.Retried")({ +export const Retried = EventV2.define({ + type: "session.next.retried", + aggregate: "sessionID", + schema: { ...Base, - type: Schema.Literal("retried"), attempt: NonNegativeInt, error: RetryError, - }) { - static create(input: BaseInput & { attempt: number; error: RetryError }) { - return new Retried({ - id: input.id ?? ID.create(), - type: "retried", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - attempt: input.attempt, - error: input.error, - }) - } - } + }, +}) +export type Retried = Schema.Schema.Type - export class Compacted extends Schema.Class("Session.Event.Compated")({ - ...Base, - type: Schema.Literal("compacted"), - auto: Schema.Boolean, - overflow: Schema.Boolean.pipe(Schema.optional), - }) { - static create(input: BaseInput & { auto: boolean; overflow?: boolean }) { - return new Compacted({ - id: input.id ?? ID.create(), - type: "compacted", - timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()), - metadata: input.metadata, - auto: input.auto, - overflow: input.overflow, - }) - } - } +export namespace Compaction { + export const Started = EventV2.define({ + type: "session.next.compaction.started", + aggregate: "sessionID", + schema: { + ...Base, + reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]), + }, + }) + export type Started = Schema.Schema.Type - export const Event = Schema.Union( - [ - Prompt, - Synthetic, - Step.Started, - Step.Ended, - Text.Started, - Text.Delta, - Text.Ended, - Tool.Input.Started, - Tool.Input.Delta, - Tool.Input.Ended, - Tool.Called, - Tool.Success, - Tool.Error, - Reasoning.Started, - Reasoning.Delta, - Reasoning.Ended, - Retried, - Compacted, - ], - { - mode: "oneOf", + export const Delta = EventV2.define({ + type: "session.next.compaction.delta", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, }, - ).pipe(Schema.toTaggedUnion("type")) - export type Event = Schema.Schema.Type - export type Type = Event["type"] + }) + + export const Ended = EventV2.define({ + type: "session.next.compaction.ended", + aggregate: "sessionID", + schema: { + ...Base, + text: Schema.String, + include: Schema.String.pipe(Schema.optional), + }, + }) + export type Ended = Schema.Schema.Type } + +export const All = Schema.Union( + [ + AgentSwitched, + ModelSwitched, + Prompted, + Synthetic, + Shell.Started, + Shell.Ended, + Step.Started, + Step.Ended, + Text.Started, + Text.Delta, + Text.Ended, + Tool.Input.Started, + Tool.Input.Delta, + Tool.Input.Ended, + Tool.Called, + Tool.Progress, + Tool.Success, + Tool.Error, + Reasoning.Started, + Reasoning.Delta, + Reasoning.Ended, + Retried, + Compaction.Started, + Compaction.Delta, + Compaction.Ended, + ], + { + mode: "oneOf", + }, +).pipe(Schema.toTaggedUnion("type")) + +// user +// assistant +// assistant +// assistant +// user +// compaction marker +// -> text +// assistant + +export type Event = Schema.Schema.Type +export type Type = Event["type"] + +export * as SessionEvent from "./session-event" diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts new file mode 100644 index 0000000000..844f6fe2d1 --- /dev/null +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -0,0 +1,411 @@ +import { produce, type WritableDraft } from "immer" +import { SessionEvent } from "./session-event" +import { SessionMessage } from "./session-message" + +export type MemoryState = { + messages: SessionMessage.Message[] +} + +export interface Adapter { + readonly getCurrentAssistant: () => SessionMessage.Assistant | undefined + readonly getCurrentCompaction: () => SessionMessage.Compaction | undefined + readonly getCurrentShell: (callID: string) => SessionMessage.Shell | undefined + readonly updateAssistant: (assistant: SessionMessage.Assistant) => void + readonly updateCompaction: (compaction: SessionMessage.Compaction) => void + readonly updateShell: (shell: SessionMessage.Shell) => void + readonly appendMessage: (message: SessionMessage.Message) => void + readonly finish: () => Result +} + +export function memory(state: MemoryState): Adapter { + const activeAssistantIndex = () => + state.messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction") + const activeShellIndex = (callID: string) => + state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + + return { + getCurrentAssistant() { + const index = activeAssistantIndex() + if (index < 0) return + const assistant = state.messages[index] + return assistant?.type === "assistant" ? assistant : undefined + }, + getCurrentCompaction() { + const index = activeCompactionIndex() + if (index < 0) return + const compaction = state.messages[index] + return compaction?.type === "compaction" ? compaction : undefined + }, + getCurrentShell(callID) { + const index = activeShellIndex(callID) + if (index < 0) return + const shell = state.messages[index] + return shell?.type === "shell" ? shell : undefined + }, + updateAssistant(assistant) { + const index = activeAssistantIndex() + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "assistant") return + state.messages[index] = assistant + }, + updateCompaction(compaction) { + const index = activeCompactionIndex() + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "compaction") return + state.messages[index] = compaction + }, + updateShell(shell) { + const index = activeShellIndex(shell.callID) + if (index < 0) return + const current = state.messages[index] + if (current?.type !== "shell") return + state.messages[index] = shell + }, + appendMessage(message) { + state.messages.push(message) + }, + finish() { + return state + }, + } +} + +export function update(adapter: Adapter, event: SessionEvent.Event): Result { + const currentAssistant = adapter.getCurrentAssistant() + type DraftAssistant = WritableDraft + type DraftTool = WritableDraft + type DraftText = WritableDraft + type DraftReasoning = WritableDraft + + const latestTool = (assistant: DraftAssistant | undefined, callID?: string) => + assistant?.content.findLast( + (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID), + ) + + const latestText = (assistant: DraftAssistant | undefined) => + assistant?.content.findLast((item): item is DraftText => item.type === "text") + + const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => + assistant?.content.findLast( + (item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID, + ) + + SessionEvent.All.match(event, { + "session.next.agent.switched": (event) => { + adapter.appendMessage( + new SessionMessage.AgentSwitched({ + id: event.id, + type: "agent-switched", + metadata: event.metadata, + agent: event.data.agent, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.model.switched": (event) => { + adapter.appendMessage( + new SessionMessage.ModelSwitched({ + id: event.id, + type: "model-switched", + metadata: event.metadata, + model: { + id: event.data.id, + providerID: event.data.providerID, + variant: event.data.variant, + }, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.prompted": (event) => { + adapter.appendMessage( + new SessionMessage.User({ + id: event.id, + type: "user", + metadata: event.metadata, + text: event.data.prompt.text, + files: event.data.prompt.files, + agents: event.data.prompt.agents, + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.synthetic": (event) => { + adapter.appendMessage( + new SessionMessage.Synthetic({ + sessionID: event.data.sessionID, + text: event.data.text, + id: event.id, + type: "synthetic", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.shell.started": (event) => { + adapter.appendMessage( + new SessionMessage.Shell({ + id: event.id, + type: "shell", + metadata: event.metadata, + callID: event.data.callID, + command: event.data.command, + output: "", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.shell.ended": (event) => { + const currentShell = adapter.getCurrentShell(event.data.callID) + if (currentShell) { + adapter.updateShell( + produce(currentShell, (draft) => { + draft.output = event.data.output + draft.time.completed = event.data.timestamp + }), + ) + } + }, + "session.next.step.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + }), + ) + } + adapter.appendMessage( + new SessionMessage.Assistant({ + id: event.id, + type: "assistant", + agent: event.data.agent, + model: event.data.model, + time: { created: event.data.timestamp }, + content: [], + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, + }), + ) + }, + "session.next.step.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + draft.finish = event.data.finish + draft.cost = event.data.cost + draft.tokens = event.data.tokens + if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, end: event.data.snapshot } + }), + ) + } + }, + "session.next.text.started": () => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "text", + text: "", + }) + }), + ) + } + }, + "session.next.text.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text += event.data.delta + }), + ) + } + }, + "session.next.text.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestText(draft) + if (match) match.text = event.data.text + }), + ) + } + }, + "session.next.tool.input.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "tool", + id: event.data.callID, + name: event.data.name, + time: { + created: event.data.timestamp, + }, + state: { + status: "pending", + input: "", + }, + }) + }), + ) + } + }, + "session.next.tool.input.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) + if (match && match.state.status === "pending") match.state.input += event.data.delta + }), + ) + } + }, + "session.next.tool.input.ended": () => {}, + "session.next.tool.called": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match) { + match.provider = event.data.provider + match.time.ran = event.data.timestamp + match.state = { + status: "running", + input: event.data.input, + structured: {}, + content: [], + } + } + }), + ) + } + }, + "session.next.tool.progress": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.state.structured = event.data.structured + match.state.content = [...event.data.content] + } + }), + ) + } + }, + "session.next.tool.success": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.provider = event.data.provider + match.time.completed = event.data.timestamp + match.state = { + status: "completed", + input: match.state.input, + structured: event.data.structured, + content: [...event.data.content], + } + } + }), + ) + } + }, + "session.next.tool.error": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestTool(draft, event.data.callID) + if (match && match.state.status === "running") { + match.provider = event.data.provider + match.time.completed = event.data.timestamp + match.state = { + status: "error", + error: event.data.error, + input: match.state.input, + structured: match.state.structured, + content: match.state.content, + } + } + }), + ) + } + }, + "session.next.reasoning.started": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.content.push({ + type: "reasoning", + id: event.data.reasoningID, + text: "", + }) + }), + ) + } + }, + "session.next.reasoning.delta": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text += event.data.delta + }), + ) + } + }, + "session.next.reasoning.ended": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + const match = latestReasoning(draft, event.data.reasoningID) + if (match) match.text = event.data.text + }), + ) + } + }, + "session.next.retried": () => {}, + "session.next.compaction.started": (event) => { + adapter.appendMessage( + new SessionMessage.Compaction({ + id: event.id, + type: "compaction", + metadata: event.metadata, + reason: event.data.reason, + summary: "", + time: { created: event.data.timestamp }, + }), + ) + }, + "session.next.compaction.delta": (event) => { + const currentCompaction = adapter.getCurrentCompaction() + if (currentCompaction) { + adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary += event.data.text + }), + ) + } + }, + "session.next.compaction.ended": (event) => { + const currentCompaction = adapter.getCurrentCompaction() + if (currentCompaction) { + adapter.updateCompaction( + produce(currentCompaction, (draft) => { + draft.summary = event.data.text + draft.include = event.data.include + }), + ) + } + }, + }) + + return adapter.finish() +} + +export * as SessionMessageUpdater from "./session-message-updater" diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts new file mode 100644 index 0000000000..8ec99bc200 --- /dev/null +++ b/packages/opencode/src/v2/session-message.ts @@ -0,0 +1,178 @@ +import { Schema } from "effect" +import { Prompt } from "./session-prompt" +import { SessionEvent } from "./session-event" +import { EventV2 } from "./event" +import { ToolOutput } from "./tool-output" +import { V2Schema } from "./schema" + +export const ID = EventV2.ID +export type ID = Schema.Schema.Type + +const Base = { + id: ID, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + }), +} + +export class AgentSwitched extends Schema.Class("Session.Message.AgentSwitched")({ + ...Base, + type: Schema.Literal("agent-switched"), + agent: SessionEvent.AgentSwitched.fields.data.fields.agent, +}) {} + +export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ + ...Base, + type: Schema.Literal("model-switched"), + model: Schema.Struct({ + id: SessionEvent.ModelSwitched.fields.data.fields.id, + providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID, + variant: SessionEvent.ModelSwitched.fields.data.fields.variant, + }), +}) {} + +export class User extends Schema.Class("Session.Message.User")({ + ...Base, + text: Prompt.fields.text, + files: Prompt.fields.files, + agents: Prompt.fields.agents, + type: Schema.Literal("user"), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + }), +}) {} + +export class Synthetic extends Schema.Class("Session.Message.Synthetic")({ + ...Base, + sessionID: SessionEvent.Synthetic.fields.data.fields.sessionID, + text: SessionEvent.Synthetic.fields.data.fields.text, + type: Schema.Literal("synthetic"), +}) {} + +export class Shell extends Schema.Class("Session.Message.Shell")({ + ...Base, + type: Schema.Literal("shell"), + callID: SessionEvent.Shell.Started.fields.data.fields.callID, + command: SessionEvent.Shell.Started.fields.data.fields.command, + output: Schema.String, + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class ToolStatePending extends Schema.Class("Session.Message.ToolState.Pending")({ + status: Schema.Literal("pending"), + input: Schema.String, +}) {} + +export class ToolStateRunning extends Schema.Class("Session.Message.ToolState.Running")({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Unknown), + structured: ToolOutput.Structured, + content: ToolOutput.Content.pipe(Schema.Array), +}) {} + +export class ToolStateCompleted extends Schema.Class("Session.Message.ToolState.Completed")({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Unknown), + attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, +}) {} + +export class ToolStateError extends Schema.Class("Session.Message.ToolState.Error")({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Unknown), + content: ToolOutput.Content.pipe(Schema.Array), + structured: ToolOutput.Structured, + error: Schema.Struct({ + type: Schema.String, + message: Schema.String, + }), +}) {} + +export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( + Schema.toTaggedUnion("status"), +) +export type ToolState = Schema.Schema.Type + +export class AssistantTool extends Schema.Class("Session.Message.Assistant.Tool")({ + type: Schema.Literal("tool"), + id: Schema.String, + name: Schema.String, + provider: Schema.Struct({ + executed: Schema.Boolean, + metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + }).pipe(Schema.optional), + state: ToolState, + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + ran: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + pruned: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class AssistantText extends Schema.Class("Session.Message.Assistant.Text")({ + type: Schema.Literal("text"), + text: Schema.String, +}) {} + +export class AssistantReasoning extends Schema.Class("Session.Message.Assistant.Reasoning")({ + type: Schema.Literal("reasoning"), + id: Schema.String, + text: Schema.String, +}) {} + +export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( + Schema.toTaggedUnion("type"), +) +export type AssistantContent = Schema.Schema.Type + +export class Assistant extends Schema.Class("Session.Message.Assistant")({ + ...Base, + type: Schema.Literal("assistant"), + agent: Schema.String, + model: SessionEvent.Step.Started.fields.data.fields.model, + content: AssistantContent.pipe(Schema.Array), + snapshot: Schema.Struct({ + start: Schema.String.pipe(Schema.optional), + end: Schema.String.pipe(Schema.optional), + }).pipe(Schema.optional), + finish: Schema.String.pipe(Schema.optional), + cost: Schema.Finite.pipe(Schema.optional), + tokens: Schema.Struct({ + input: Schema.Finite, + output: Schema.Finite, + reasoning: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), + }).pipe(Schema.optional), + error: Schema.String.pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), +}) {} + +export class Compaction extends Schema.Class("Session.Message.Compaction")({ + type: Schema.Literal("compaction"), + reason: SessionEvent.Compaction.Started.fields.data.fields.reason, + summary: Schema.String, + include: Schema.String.pipe(Schema.optional), + ...Base, +}) {} + +export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthetic, Shell, Assistant, Compaction]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Session.Message" }) + +export type Message = Schema.Schema.Type + +export type Type = Message["type"] + +export * as SessionMessage from "./session-message" diff --git a/packages/opencode/src/v2/session-prompt.ts b/packages/opencode/src/v2/session-prompt.ts new file mode 100644 index 0000000000..86d8e52eb7 --- /dev/null +++ b/packages/opencode/src/v2/session-prompt.ts @@ -0,0 +1,36 @@ +import * as Schema from "effect/Schema" + +export class Source extends Schema.Class("Prompt.Source")({ + start: Schema.Finite, + end: Schema.Finite, + text: Schema.String, +}) {} + +export class FileAttachment extends Schema.Class("Prompt.FileAttachment")({ + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + source: Source.pipe(Schema.optional), +}) { + static create(input: FileAttachment) { + return new FileAttachment({ + uri: input.uri, + mime: input.mime, + name: input.name, + description: input.description, + source: input.source, + }) + } +} + +export class AgentAttachment extends Schema.Class("Prompt.AgentAttachment")({ + name: Schema.String, + source: Source.pipe(Schema.optional), +}) {} + +export class Prompt extends Schema.Class("Prompt")({ + text: Schema.String, + files: Schema.Array(FileAttachment).pipe(Schema.optional), + agents: Schema.Array(AgentAttachment).pipe(Schema.optional), +}) {} diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 2bac11f4fe..1777b875aa 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -1,69 +1,279 @@ -import { Context, Layer, Schema, Effect } from "effect" -import { SessionEntry } from "./session-entry" -import { Struct } from "effect" -import { Session } from "@/session/session" +import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" +import { WorkspaceID } from "@/control-plane/schema" +import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" +import * as Database from "@/storage/db" +import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { SessionMessage } from "./session-message" +import type { Prompt } from "./session-prompt" +import { EventV2 } from "./event" +import { ProjectID } from "@/project/schema" +import { ModelID, ProviderID } from "@/provider/schema" +import { SessionEvent } from "./session-event" +import { V2Schema } from "./schema" -export const ID = SessionID +export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ + identifier: "Session.Delivery", +}) +export type Delivery = Schema.Schema.Type -export type ID = Schema.Schema.Type - -export class PromptInput extends Schema.Class("Session.PromptInput")({ - ...Struct.omit(SessionEntry.User.fields, ["time", "type"]), - id: Schema.optionalKey(SessionEntry.ID), - sessionID: ID, -}) {} - -export class CreateInput extends Schema.Class("Session.CreateInput")({ - id: Schema.optionalKey(ID), -}) {} +export const DefaultDelivery = "immediate" satisfies Delivery export class Info extends Schema.Class("Session.Info")({ - id: ID, + id: SessionID, + parentID: SessionID.pipe(Schema.optional), + projectID: ProjectID, + workspaceID: WorkspaceID.pipe(Schema.optional), + path: Schema.String.pipe(Schema.optional), + agent: Schema.String.pipe(Schema.optional), model: Schema.Struct({ - id: Schema.String, - providerID: Schema.String, - modelID: Schema.String, + id: ModelID, + providerID: ProviderID, + variant: Schema.String.pipe(Schema.optional), }).pipe(Schema.optional), + time: Schema.Struct({ + created: V2Schema.DateTimeUtcFromMillis, + updated: V2Schema.DateTimeUtcFromMillis, + archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + }), + title: Schema.String, + /* + slug: Schema.String, + directory: Schema.String, + path: optionalOmitUndefined(Schema.String), + parentID: optionalOmitUndefined(SessionID), + summary: optionalOmitUndefined(Summary), + share: optionalOmitUndefined(Share), + title: Schema.String, + version: Schema.String, + time: Time, + permission: optionalOmitUndefined(Permission.Ruleset), + revert: optionalOmitUndefined(Revert), + */ }) {} export interface Interface { - fromID: (id: ID) => Effect.Effect - create: (input: CreateInput) => Effect.Effect - prompt: (input: PromptInput) => Effect.Effect + readonly list: (input: { + limit?: number + order?: "asc" | "desc" + directory?: string + path?: string + workspaceID?: WorkspaceID + roots?: boolean + start?: number + search?: string + cursor?: { + id: SessionID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly messages: (input: { + sessionID: SessionID + limit?: number + order?: "asc" | "desc" + cursor?: { + id: SessionMessage.ID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly context: (sessionID: SessionID) => Effect.Effect + readonly prompt: (input: { + id?: EventV2.ID + sessionID: SessionID + prompt: Prompt + delivery?: Delivery + }) => Effect.Effect + readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect + readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect + readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect + readonly switchModel: (input: { + sessionID: SessionID + id: ModelID + providerID: ProviderID + variant?: string + }) => Effect.Effect + readonly compact: (sessionID: SessionID) => Effect.Effect + readonly wait: (sessionID: SessionID) => Effect.Effect } -export class Service extends Context.Service()("Session.Service") {} +export class Service extends Context.Service()("@opencode/v2/Session") {} -export const layer = Layer.effect(Service)( +export const layer = Layer.effect( + Service, Effect.gen(function* () { - const session = yield* Session.Service + const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) - const create: Interface["create"] = Effect.fn("Session.create")(function* (_input) { - throw new Error("Not implemented") - }) + const decode = (row: typeof SessionMessageTable.$inferSelect) => + decodeMessage({ ...row.data, id: row.id, type: row.type }) - const prompt: Interface["prompt"] = Effect.fn("Session.prompt")(function* (_input) { - throw new Error("Not implemented") - }) + function fromRow(row: typeof SessionTable.$inferSelect): Info { + return { + id: SessionID.make(row.id), + projectID: ProjectID.make(row.project_id), + workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined, + title: row.title, + parentID: row.parent_id ? SessionID.make(row.parent_id) : undefined, + path: row.path ?? "", + agent: row.agent ?? undefined, + model: row.model + ? { + id: ModelID.make(row.model.id), + providerID: ProviderID.make(row.model.providerID), + variant: row.model.variant, + } + : undefined, + time: { + created: DateTime.makeUnsafe(row.time_created), + updated: DateTime.makeUnsafe(row.time_updated), + archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, + }, + } + } - const fromID: Interface["fromID"] = Effect.fn("Session.fromID")(function* (id) { - const match = yield* session.get(id) - return fromV1(match) - }) + const result: Interface = { + list: Effect.fn("V2Session.list")(function* (input) { + const direction = input.cursor?.direction ?? "next" + let order = input.order ?? "desc" + // Query the adjacent rows in reverse, then flip them back into the requested order below. + if (direction === "previous" && order === "asc") order = "desc" + if (direction === "previous" && order === "desc") order = "asc" + const conditions: SQL[] = [] + if (input.directory) conditions.push(eq(SessionTable.directory, input.directory)) + if (input.path) + conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!) + if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + if (input.roots) conditions.push(isNull(SessionTable.parent_id)) + if (input.start) conditions.push(gte(SessionTable.time_created, input.start)) + if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (input.cursor) { + conditions.push( + order === "asc" + ? or( + gt(SessionTable.time_created, input.cursor.time), + and(eq(SessionTable.time_created, input.cursor.time), gt(SessionTable.id, input.cursor.id)), + )! + : or( + lt(SessionTable.time_created, input.cursor.time), + and(eq(SessionTable.time_created, input.cursor.time), lt(SessionTable.id, input.cursor.id)), + )!, + ) + } + const query = Database.Client() + .select() + .from(SessionTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy( + order === "asc" ? asc(SessionTable.time_created) : desc(SessionTable.time_created), + order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), + ) - return Service.of({ - create, - prompt, - fromID, - }) + const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() + return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) + }), + messages: Effect.fn("V2Session.messages")(function* (input) { + const direction = input.cursor?.direction ?? "next" + let order = input.order ?? "desc" + // Query the adjacent rows in reverse, then flip them back into the requested order below. + if (direction === "previous" && order === "asc") order = "desc" + if (direction === "previous" && order === "desc") order = "asc" + const boundary = input.cursor + ? order === "asc" + ? or( + gt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + gt(SessionMessageTable.id, input.cursor.id), + ), + ) + : or( + lt(SessionMessageTable.time_created, input.cursor.time), + and( + eq(SessionMessageTable.time_created, input.cursor.time), + lt(SessionMessageTable.id, input.cursor.id), + ), + ) + : undefined + const where = boundary + ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) + : eq(SessionMessageTable.session_id, input.sessionID) + + const rows = Database.use((db) => { + const query = db + .select() + .from(SessionMessageTable) + .where(where) + .orderBy( + order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created), + order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id), + ) + const rows = input.limit === undefined ? query.all() : query.limit(input.limit).all() + return direction === "previous" ? rows.toReversed() : rows + }) + return rows.map((row) => decode(row)) + }), + context: Effect.fn("V2Session.context")(function* (sessionID) { + const rows = Database.use((db) => { + const compaction = db + .select() + .from(SessionMessageTable) + .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) + .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id)) + .limit(1) + .get() + + return db + .select() + .from(SessionMessageTable) + .where( + and( + eq(SessionMessageTable.session_id, sessionID), + compaction + ? or( + gt(SessionMessageTable.time_created, compaction.time_created), + and( + eq(SessionMessageTable.time_created, compaction.time_created), + gte(SessionMessageTable.id, compaction.id), + ), + ) + : undefined, + ), + ) + .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id)) + .all() + }) + return rows.map((row) => decode(row)) + }), + prompt: Effect.fn("V2Session.prompt")(function* (_input) { + return {} as any + }), + shell: Effect.fn("V2Session.shell")(function* (_input) {}), + skill: Effect.fn("V2Session.skill")(function* (_input) {}), + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + EventV2.run(SessionEvent.AgentSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + agent: input.agent, + }) + }), + switchModel: Effect.fn("V2Session.switchModel")(function* (input) { + EventV2.run(SessionEvent.ModelSwitched.Sync, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(Date.now()), + id: input.id, + providerID: input.providerID, + variant: input.variant, + }) + }), + compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), + wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), + } + + return Service.of(result) }), ) -function fromV1(input: Session.Info): Info { - return new Info({ - id: ID.make(input.id), - }) -} +export const defaultLayer = layer export * as SessionV2 from "./session" diff --git a/packages/opencode/src/v2/tool-output.ts b/packages/opencode/src/v2/tool-output.ts new file mode 100644 index 0000000000..dee2bb11ed --- /dev/null +++ b/packages/opencode/src/v2/tool-output.ts @@ -0,0 +1,18 @@ +export * as ToolOutput from "./tool-output" +import { Schema } from "effect" + +export class TextContent extends Schema.Class("Tool.TextContent")({ + type: Schema.Literal("text"), + text: Schema.String, +}) {} + +export class FileContent extends Schema.Class("Tool.FileContent")({ + type: Schema.Literal("file"), + uri: Schema.String, + mime: Schema.String, + name: Schema.String.pipe(Schema.optional), +}) {} + +export const Content = Schema.Union([TextContent, FileContent]).pipe(Schema.toTaggedUnion("type")) + +export const Structured = Schema.Record(Schema.String, Schema.Any) diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts index 9a92fc5072..2722757ab9 100644 --- a/packages/opencode/test/acp/event-subscription.test.ts +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -59,6 +59,7 @@ function toolEvent( raw: opts.raw, } const payload: EventMessagePartUpdated = { + id: `evt_${opts.callID}`, type: "message.part.updated", properties: { sessionID: sessionId, diff --git a/packages/opencode/test/cli/tui/use-event.test.tsx b/packages/opencode/test/cli/tui/use-event.test.tsx index 5b0fcad3c9..78253361b7 100644 --- a/packages/opencode/test/cli/tui/use-event.test.tsx +++ b/packages/opencode/test/cli/tui/use-event.test.tsx @@ -25,6 +25,7 @@ function event(payload: Event, input: { directory: string; workspace?: string }) function vcs(branch: string): Event { return { + id: `evt_vcs_${branch}`, type: "vcs.branch.updated", properties: { branch, @@ -34,6 +35,7 @@ function vcs(branch: string): Event { function update(version: string): Event { return { + id: `evt_update_${version}`, type: "installation.update-available", properties: { version, diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 479da7f518..b408f7ef11 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -34,6 +34,7 @@ process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") process.env["XDG_STATE_HOME"] = path.join(dir, "state") process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json") +process.env["OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"] = "true" // Set test home directory to isolate tests from user's actual home directory // This prevents tests from picking up real user configs/skills from ~/.claude/skills @@ -79,7 +80,7 @@ delete process.env["OPENCODE_SERVER_USERNAME"] process.env["OPENCODE_DB"] = ":memory:" // Now safe to import from src/ -const Log = await import("@opencode-ai/core/util/log") +const { Log } = await import("@opencode-ai/core/util/log") const { initProjectors } = await import("../src/server/projectors") void Log.init({ diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 352fb2e2fa..b7ffa0ca5e 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -226,7 +226,14 @@ describe("HttpApi server", () => { const effectRoutes = openApiRouteKeys(effectOpenApi()) expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) - expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([]) + expect(effectRoutes.filter((route) => !honoRoutes.includes(route))).toEqual([ + "GET /api/session", + "GET /api/session/{sessionID}/context", + "GET /api/session/{sessionID}/message", + "POST /api/session/{sessionID}/compact", + "POST /api/session/{sessionID}/prompt", + "POST /api/session/{sessionID}/wait", + ]) }) test("matches generated OpenAPI route parameters", async () => { diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index d7e48240a9..940efed9c3 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -27,6 +27,14 @@ async function readFirstChunk(response: Response) { return new TextDecoder().decode(result.value) } +async function readFirstEvent(response: Response) { + return JSON.parse((await readFirstChunk(response)).replace(/^data: /, "")) as { + id?: string + type: string + properties: Record + } +} + afterEach(async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original await disposeAllInstances() @@ -43,7 +51,7 @@ describe("event HttpApi bridge", () => { expect(response.headers.get("cache-control")).toBe("no-cache, no-transform") expect(response.headers.get("x-accel-buffering")).toBe("no") expect(response.headers.get("x-content-type-options")).toBe("nosniff") - expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n') + expect(await readFirstEvent(response)).toMatchObject({ type: "server.connected", properties: {} }) }) test("matches legacy first event frame", async () => { @@ -52,6 +60,9 @@ describe("event HttpApi bridge", () => { const legacy = await app(false).request(EventPaths.event, { headers }) const effect = await app(true).request(EventPaths.event, { headers }) - expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy)) + const legacyEvent = await readFirstEvent(legacy) + const effectEvent = await readFirstEvent(effect) + expect(effectEvent.type).toBe(legacyEvent.type) + expect(effectEvent.properties).toEqual(legacyEvent.properties) }) }) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 70fe2d81b3..d96347bed8 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -17,7 +17,9 @@ import { Session } from "@/session/session" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" -import { SessionTable } from "@/session/session.sql" +import { SessionMessageTable, SessionTable } from "@/session/session.sql" +import { SessionMessage } from "../../src/v2/session-message" +import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" @@ -203,6 +205,45 @@ describe("session HttpApi", () => { { headers }, ), ).toMatchObject({ info: { id: message.info.id } }) + + yield* Effect.promise(() => + WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const message = new SessionMessage.Assistant({ + id: SessionMessage.ID.create(), + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + time: { created: DateTime.makeUnsafe(1) }, + content: [], + }) + Database.use((db) => + db + .insert(SessionMessageTable) + .values([ + { + id: message.id, + session_id: parent.id, + type: message.type, + time_created: 1, + data: { + time: { created: 1 }, + agent: message.agent, + model: message.model, + content: message.content, + } as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>, + }, + ]) + .run(), + ) + }, + }), + ) + + expect( + (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })).items, + ).toMatchObject([{ type: "assistant" }]) }), ), ) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index df83adb8d4..0d02d9918a 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -20,6 +20,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" +import { SessionV2 } from "../../src/v2/session" import { ModelID, ProviderID } from "../../src/provider/schema" import type { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" @@ -597,6 +598,15 @@ describe("session.compaction.create", () => { auto: true, overflow: true, }) + + const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( + Effect.provide(SessionV2.defaultLayer), + ) + expect(v2.at(-1)).toMatchObject({ + type: "compaction", + reason: "auto", + summary: "", + }) }), ), ) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5330569401..a602c0c8d7 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -19,6 +19,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" import { Session } from "@/session/session" +import { SessionMessageTable } from "../../src/session/session.sql" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -31,6 +32,7 @@ import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { SessionV2 } from "../../src/v2/session" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Shell } from "../../src/shell/shell" @@ -39,6 +41,7 @@ import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" import * as Log from "@opencode-ai/core/util/log" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import * as Database from "../../src/storage/db" import { Ripgrep } from "../../src/file/ripgrep" import { Format } from "../../src/format" import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" @@ -371,6 +374,47 @@ it.live("loop calls LLM and returns assistant message", () => ), ) +it.live("prompt emits v2 prompted and synthetic events", () => + provideTmpdirServer( + Effect.fnUntraced(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "hello v2" }, + { + type: "file", + mime: "text/plain", + filename: "note.txt", + url: "data:text/plain;base64,bm90ZSBjb250ZW50", + }, + ], + }) + + const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( + Effect.provide(SessionV2.layer), + ) + const row = Database.use((db) => + db.select().from(SessionMessageTable).where(Database.eq(SessionMessageTable.session_id, chat.id)).get(), + ) + expect(messages.find((message) => message.type === "user")).toMatchObject({ type: "user", text: "hello v2" }) + expect(typeof row?.data.time.created).toBe("number") + expect(messages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "synthetic", text: expect.stringContaining("Called the Read tool") }), + expect.objectContaining({ type: "synthetic", text: "note content" }), + ]), + ) + }), + { git: true, config: providerCfg }, + ), +) + it.live("static loop returns assistant text through local provider", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts deleted file mode 100644 index defce40c14..0000000000 --- a/packages/opencode/test/session/session-entry-stepper.test.ts +++ /dev/null @@ -1,916 +0,0 @@ -import { describe, expect, test } from "bun:test" -import * as DateTime from "effect/DateTime" -import * as FastCheck from "effect/testing/FastCheck" -import { SessionEntry } from "../../src/v2/session-entry" -import { SessionEntryStepper } from "../../src/v2/session-entry-stepper" -import { SessionEvent } from "../../src/v2/session-event" - -const time = (n: number) => DateTime.makeUnsafe(n) - -const word = FastCheck.string({ minLength: 1, maxLength: 8 }) -const text = FastCheck.string({ maxLength: 16 }) -const texts = FastCheck.array(text, { maxLength: 8 }) -const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 })) -const dict = FastCheck.dictionary(word, val, { maxKeys: 4 }) -const files = FastCheck.array( - word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })), - { maxLength: 2 }, -) - -function maybe(arb: FastCheck.Arbitrary) { - return FastCheck.oneof(FastCheck.constant(undefined), arb) -} - -function assistant() { - return new SessionEntry.Assistant({ - id: SessionEvent.ID.create(), - type: "assistant", - time: { created: time(0) }, - content: [], - retries: [], - }) -} - -function retryError(message: string) { - return new SessionEvent.RetryError({ - message, - isRetryable: true, - }) -} - -function retry(attempt: number, message: string, created: number) { - return new SessionEntry.AssistantRetry({ - attempt, - error: retryError(message), - time: { - created: time(created), - }, - }) -} - -function memoryState() { - const state: SessionEntryStepper.MemoryState = { - entries: [], - pending: [], - } - return state -} - -function active() { - const state: SessionEntryStepper.MemoryState = { - entries: [assistant()], - pending: [], - } - return state -} - -function run(events: SessionEvent.Event[], state = memoryState()) { - return events.reduce((state, event) => SessionEntryStepper.step(state, event), state) -} - -function last(state: SessionEntryStepper.MemoryState) { - const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant") - expect(entry?.type).toBe("assistant") - return entry?.type === "assistant" ? entry : undefined -} - -function texts_of(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text") -} - -function reasons(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning") -} - -function tools(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool") -} - -function tool(state: SessionEntryStepper.MemoryState, callID: string) { - return tools(state).find((x) => x.callID === callID) -} - -function retriesOf(state: SessionEntryStepper.MemoryState) { - const entry = last(state) - if (!entry) return [] - return entry.retries ?? [] -} - -function adapterStore() { - return { - committed: [] as SessionEntry.Entry[], - deferred: [] as SessionEntry.Entry[], - } -} - -function adapterFor(store: ReturnType): SessionEntryStepper.Adapter { - const activeAssistantIndex = () => - store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed) - - const getCurrentAssistant = () => { - const index = activeAssistantIndex() - if (index < 0) return - const assistant = store.committed[index] - return assistant?.type === "assistant" ? assistant : undefined - } - - return { - getCurrentAssistant, - updateAssistant(assistant) { - const index = activeAssistantIndex() - if (index < 0) return - const current = store.committed[index] - if (current?.type !== "assistant") return - store.committed[index] = assistant - }, - appendEntry(entry) { - store.committed.push(entry) - }, - appendPending(entry) { - store.deferred.push(entry) - }, - finish() { - return store - }, - } -} - -describe("session-entry-stepper", () => { - describe("stepWith", () => { - test("reduces through a custom adapter", () => { - const store = adapterStore() - store.committed.push(assistant()) - - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) })) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }), - ) - SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) })) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(7), - }), - ) - - expect(store.deferred).toHaveLength(1) - expect(store.deferred[0]?.type).toBe("user") - expect(store.committed).toHaveLength(1) - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].content).toEqual([ - { type: "reasoning", text: "thought" }, - { type: "text", text: "world" }, - ]) - expect(store.committed[0].time.completed).toEqual(time(7)) - }) - - test("aggregates retry events onto the current assistant", () => { - const store = adapterStore() - store.committed.push(assistant()) - - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) - SessionEntryStepper.stepWith( - adapterFor(store), - SessionEvent.Retried.create({ - attempt: 2, - error: retryError("provider overloaded"), - timestamp: time(2), - }), - ) - - expect(store.committed[0]?.type).toBe("assistant") - if (store.committed[0]?.type !== "assistant") return - - expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)]) - }) - }) - - describe("memory", () => { - test("tracks and replaces the current assistant", () => { - const state = active() - const adapter = SessionEntryStepper.memory(state) - const current = adapter.getCurrentAssistant() - - expect(current?.type).toBe("assistant") - if (!current) return - - adapter.updateAssistant( - new SessionEntry.Assistant({ - ...current, - content: [new SessionEntry.AssistantText({ type: "text", text: "done" })], - time: { - ...current.time, - completed: time(1), - }, - }), - ) - - expect(adapter.getCurrentAssistant()).toBeUndefined() - expect(state.entries[0]?.type).toBe("assistant") - if (state.entries[0]?.type !== "assistant") return - - expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }]) - expect(state.entries[0].time.completed).toEqual(time(1)) - }) - - test("appends committed and pending entries", () => { - const state = memoryState() - const adapter = SessionEntryStepper.memory(state) - const committed = SessionEntry.User.fromEvent( - SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }), - ) - const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) })) - - adapter.appendEntry(committed) - adapter.appendPending(pending) - - expect(state.entries).toEqual([committed]) - expect(state.pending).toEqual([pending]) - }) - - test("stepWith through memory records reasoning", () => { - const state = active() - - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }), - ) - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }), - ) - - expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }]) - }) - - test("stepWith through memory records retries", () => { - const state = active() - - SessionEntryStepper.stepWith( - SessionEntryStepper.memory(state), - SessionEvent.Retried.create({ - attempt: 1, - error: retryError("rate limited"), - timestamp: time(1), - }), - ) - - expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)]) - }) - }) - - describe("step", () => { - describe("seeded pending assistant", () => { - test("stores prompts in entries when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("user") - if (next.entries[0]?.type !== "user") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("stores prompts in pending when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - active(), - SessionEvent.Prompt.create({ text: body, timestamp: time(1) }), - ) - expect(next.pending).toHaveLength(1) - expect(next.pending[0]?.type).toBe("user") - if (next.pending[0]?.type !== "user") return - expect(next.pending[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("accumulates text deltas on the latest text part", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = parts.reduce( - (state, part, i) => - SessionEntryStepper.step( - state, - SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }), - ), - SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })), - ) - - expect(texts_of(next)).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - }), - { numRuns: 100 }, - ) - }) - - test("routes later text deltas to the latest text segment", () => { - FastCheck.assert( - FastCheck.property(texts, texts, (a, b) => { - const next = run( - [ - SessionEvent.Text.Started.create({ timestamp: time(1) }), - ...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }), - ...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })), - ], - active(), - ) - - expect(texts_of(next)).toEqual([ - { type: "text", text: a.join("") }, - { type: "text", text: b.join("") }, - ]) - }), - { numRuns: 50 }, - ) - }) - - test("reasoning.ended replaces buffered reasoning text", () => { - FastCheck.assert( - FastCheck.property(texts, text, (parts, end) => { - const next = run( - [ - SessionEvent.Reasoning.Started.create({ timestamp: time(1) }), - ...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }), - ], - active(), - ) - - expect(reasons(next)).toEqual([ - { - type: "reasoning", - text: end, - }, - ]) - }), - { numRuns: 100 }, - ) - }) - - test("tool.success completes the latest running tool", () => { - FastCheck.assert( - FastCheck.property( - word, - word, - dict, - maybe(text), - maybe(dict), - maybe(files), - texts, - (callID, title, input, output, metadata, attachments, parts) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - ...parts.map((x, i) => - SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }), - ), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(parts.length + 2), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(parts.length + 3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("completed") - if (match?.state.status !== "completed") return - - expect(match.time.ran).toEqual(time(parts.length + 2)) - expect(match.state.input).toEqual(input) - expect(match.state.output).toBe(output ?? "") - expect(match.state.title).toBe(title) - expect(match.state.metadata).toEqual(metadata ?? {}) - expect(match.state.attachments).toEqual(attachments ?? []) - }, - ), - { numRuns: 50 }, - ) - }) - - test("tool.error completes the latest running tool with an error", () => { - FastCheck.assert( - FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Error.create({ - callID, - error, - metadata, - provider: { executed: true }, - timestamp: time(3), - }), - ], - active(), - ) - - const match = tool(next, callID) - expect(match?.state.status).toBe("error") - if (match?.state.status !== "error") return - - expect(match.time.ran).toEqual(time(2)) - expect(match.state.input).toEqual(input) - expect(match.state.error).toBe(error) - expect(match.state.metadata).toEqual(metadata ?? {}) - }), - { numRuns: 50 }, - ) - }) - - test("tool.success is ignored before tool.called promotes the tool to running", () => { - FastCheck.assert( - FastCheck.property(word, word, (callID, title) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Success.create({ - callID, - title, - provider: { executed: true }, - timestamp: time(2), - }), - ], - active(), - ) - const match = tool(next, callID) - expect(match?.state).toEqual({ - status: "pending", - input: "", - }) - }), - { numRuns: 50 }, - ) - }) - - test("step.ended copies completion fields onto the pending assistant", () => { - FastCheck.assert( - FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => { - const event = SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(n), - }) - const next = SessionEntryStepper.step(active(), event) - const entry = last(next) - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.time.completed).toEqual(event.timestamp) - expect(entry.cost).toBe(event.cost) - expect(entry.tokens).toEqual(event.tokens) - }), - { numRuns: 50 }, - ) - }) - }) - - describe("known reducer gaps", () => { - test("prompt appends immutably when no assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = memoryState() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.entries).toHaveLength(0) - expect(next.entries).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) - - test("prompt appends immutably when an assistant is pending", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const old = active() - const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) })) - expect(old).not.toBe(next) - expect(old.pending).toHaveLength(0) - expect(next.pending).toHaveLength(1) - }), - { numRuns: 50 }, - ) - }) - - test("step.started creates an assistant consumed by follow-up events", () => { - FastCheck.assert( - FastCheck.property(texts, (parts) => { - const next = run([ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - const entry = last(next) - - expect(entry).toBeDefined() - if (!entry) return - - expect(entry.content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(entry.time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 100 }, - ) - }) - - test("replays prompt -> step -> text -> step.ended", () => { - FastCheck.assert( - FastCheck.property(word, texts, (body, parts) => { - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Text.Started.create({ timestamp: time(2) }), - ...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(parts.length + 3), - }), - ]) - - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("user") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[1]?.type !== "assistant") return - - expect(next.entries[1].content).toEqual([ - { - type: "text", - text: parts.join(""), - }, - ]) - expect(next.entries[1].time.completed).toEqual(time(parts.length + 3)) - }), - { numRuns: 50 }, - ) - }) - - test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => { - FastCheck.assert( - FastCheck.property( - word, - texts, - text, - dict, - word, - maybe(text), - maybe(dict), - maybe(files), - (body, reason, end, input, title, output, metadata, attachments) => { - const callID = "call" - const next = run([ - SessionEvent.Prompt.create({ text: body, timestamp: time(0) }), - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - SessionEvent.Reasoning.Started.create({ timestamp: time(2) }), - ...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })), - SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }), - SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }), - SessionEvent.Tool.Called.create({ - callID, - tool: "bash", - input, - provider: { executed: true }, - timestamp: time(reason.length + 5), - }), - SessionEvent.Tool.Success.create({ - callID, - title, - output, - metadata, - attachments, - provider: { executed: true }, - timestamp: time(reason.length + 6), - }), - SessionEvent.Step.Ended.create({ - reason: "stop", - cost: 1, - tokens: { - input: 1, - output: 2, - reasoning: 3, - cache: { - read: 4, - write: 5, - }, - }, - timestamp: time(reason.length + 7), - }), - ]) - - expect(next.entries.at(-1)?.type).toBe("assistant") - const entry = next.entries.at(-1) - if (entry?.type !== "assistant") return - - expect(entry.content).toHaveLength(2) - expect(entry.content[0]).toEqual({ - type: "reasoning", - text: end, - }) - expect(entry.content[1]?.type).toBe("tool") - if (entry.content[1]?.type !== "tool") return - expect(entry.content[1].state.status).toBe("completed") - expect(entry.time.completed).toEqual(time(reason.length + 7)) - }, - ), - { numRuns: 50 }, - ) - }) - - test("starting a new step completes the old assistant and appends a new active assistant", () => { - const next = run( - [ - SessionEvent.Step.Started.create({ - model: { - id: "model", - providerID: "provider", - }, - timestamp: time(1), - }), - ], - active(), - ) - expect(next.entries).toHaveLength(2) - expect(next.entries[0]?.type).toBe("assistant") - expect(next.entries[1]?.type).toBe("assistant") - if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return - - expect(next.entries[0].time.completed).toEqual(time(1)) - expect(next.entries[1].time.created).toEqual(time(1)) - expect(next.entries[1].time.completed).toBeUndefined() - }) - - test("handles sequential tools independently", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, (a, b, title, error) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(2), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title, - output: "done", - provider: { executed: true }, - timestamp: time(3), - }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "bash", - input: b, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Error.create({ - callID: "b", - error, - provider: { executed: true }, - timestamp: time(6), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - if (first?.state.status !== "completed") return - expect(first.state.input).toEqual(a) - expect(first.state.output).toBe("done") - expect(first.state.title).toBe(title) - - expect(second?.state.status).toBe("error") - if (second?.state.status !== "error") return - expect(second.state.input).toEqual(b) - expect(second.state.error).toBe(error) - }), - { numRuns: 50 }, - ) - }) - - test("routes tool events by callID when tool streams interleave", () => { - FastCheck.assert( - FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => { - const next = run( - [ - SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), - SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }), - SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }), - SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }), - SessionEvent.Tool.Called.create({ - callID: "a", - tool: "bash", - input: a, - provider: { executed: true }, - timestamp: time(5), - }), - SessionEvent.Tool.Called.create({ - callID: "b", - tool: "grep", - input: b, - provider: { executed: true }, - timestamp: time(6), - }), - SessionEvent.Tool.Success.create({ - callID: "a", - title: titleA, - output: "done-a", - provider: { executed: true }, - timestamp: time(7), - }), - SessionEvent.Tool.Success.create({ - callID: "b", - title: titleB, - output: "done-b", - provider: { executed: true }, - timestamp: time(8), - }), - ], - active(), - ) - - const first = tool(next, "a") - const second = tool(next, "b") - - expect(first?.state.status).toBe("completed") - expect(second?.state.status).toBe("completed") - if (first?.state.status !== "completed" || second?.state.status !== "completed") return - - expect(first.state.input).toEqual(a) - expect(second.state.input).toEqual(b) - expect(first.state.title).toBe(titleA) - expect(second.state.title).toBe(titleB) - }), - { numRuns: 50 }, - ) - }) - - test("records synthetic events", () => { - FastCheck.assert( - FastCheck.property(word, (body) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("synthetic") - if (next.entries[0]?.type !== "synthetic") return - expect(next.entries[0].text).toBe(body) - }), - { numRuns: 50 }, - ) - }) - - test("records compaction events", () => { - FastCheck.assert( - FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => { - const next = SessionEntryStepper.step( - memoryState(), - SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }), - ) - expect(next.entries).toHaveLength(1) - expect(next.entries[0]?.type).toBe("compaction") - if (next.entries[0]?.type !== "compaction") return - expect(next.entries[0].auto).toBe(auto) - expect(next.entries[0].overflow).toBe(overflow) - }), - { numRuns: 50 }, - ) - }) - }) - }) -}) diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 0afbb18317..234c5246ee 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -124,7 +124,7 @@ describe("SyncEvent", () => { yield* SyncEvent.use.run(Created, { id: "evt_1", name: "test" }) yield* Effect.promise(() => received) expect(events).toHaveLength(1) - expect(events[0]).toEqual({ + expect(events[0]).toMatchObject({ type: "item.created", properties: { id: "evt_1", diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts new file mode 100644 index 0000000000..128177167c --- /dev/null +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -0,0 +1,203 @@ +import { expect, test } from "bun:test" +import * as DateTime from "effect/DateTime" +import { SessionID } from "../../src/session/schema" +import { EventV2 } from "../../src/v2/event" +import { SessionEvent } from "../../src/v2/session-event" +import { SessionMessageUpdater } from "../../src/v2/session-message-updater" + +test("step snapshots carry over to assistant messages", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + snapshot: "before", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + finish: "stop", + cost: 0, + tokens: { + input: 1, + output: 2, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + snapshot: "after", + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].snapshot).toEqual({ start: "before", end: "after" }) + expect(state.messages[0].finish).toBe("stop") +}) + +test("text ended populates assistant text content", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.text.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.text.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + text: "hello assistant", + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }]) +}) + +test("tool completion stores completed timestamp", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + const callID = "call" + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.step.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.input.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + callID, + name: "bash", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.called", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + callID, + tool: "bash", + input: { command: "pwd" }, + provider: { executed: true, metadata: { source: "provider" } }, + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.tool.success", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(4), + callID, + structured: {}, + content: [{ type: "text", text: "/tmp" }], + provider: { executed: true, metadata: { status: "done" } }, + }, + } satisfies SessionEvent.Event) + + expect(state.messages[0]?.type).toBe("assistant") + if (state.messages[0]?.type !== "assistant") return + expect(state.messages[0].content[0]?.type).toBe("tool") + if (state.messages[0].content[0]?.type !== "tool") return + expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4)) + expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { status: "done" } }) +}) + +test("compaction events reduce to compaction message", () => { + const state: SessionMessageUpdater.MemoryState = { messages: [] } + const sessionID = SessionID.make("session") + const id = EventV2.ID.create() + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id, + type: "session.next.compaction.started", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(1), + reason: "auto", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.delta", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(2), + text: "hello ", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.delta", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(3), + text: "summary", + }, + } satisfies SessionEvent.Event) + + SessionMessageUpdater.update(SessionMessageUpdater.memory(state), { + id: EventV2.ID.create(), + type: "session.next.compaction.ended", + data: { + sessionID, + timestamp: DateTime.makeUnsafe(4), + text: "final summary", + include: "recent context", + }, + } satisfies SessionEvent.Event) + + expect(state.messages).toHaveLength(1) + expect(state.messages[0]).toMatchObject({ + id, + type: "compaction", + reason: "auto", + summary: "final summary", + include: "recent context", + time: { created: DateTime.makeUnsafe(1) }, + }) +}) diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index e920cc0fdb..c490a0be70 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -9,7 +9,7 @@ import path from "path" import { createClient } from "@hey-api/openapi-ts" -const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "httpapi" ? "httpapi" : "hono" +const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi" const opencode = path.resolve(dir, "../../opencode") if (openapiSource === "httpapi") { diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 67261d7499..74c5844626 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -20,10 +20,10 @@ import type { ConfigUpdateErrors, ConfigUpdateResponses, EventSubscribeResponses, - EventTuiCommandExecute, - EventTuiPromptAppend, - EventTuiSessionSelect, - EventTuiToastShow, + EventTuiCommandExecute2, + EventTuiPromptAppend2, + EventTuiSessionSelect2, + EventTuiToastShow2, ExperimentalConsoleGetResponses, ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, @@ -90,6 +90,7 @@ import type { ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, + Prompt, ProviderAuthResponses, ProviderListResponses, ProviderOauthAuthorizeErrors, @@ -126,6 +127,7 @@ import type { SessionDeleteMessageErrors, SessionDeleteMessageResponses, SessionDeleteResponses, + SessionDelivery, SessionDiffResponses, SessionForkResponses, SessionGetErrors, @@ -187,6 +189,14 @@ import type { TuiSelectSessionResponses, TuiShowToastResponses, TuiSubmitPromptResponses, + V2SessionCompactResponses, + V2SessionContextResponses, + V2SessionListErrors, + V2SessionListResponses, + V2SessionMessagesErrors, + V2SessionMessagesResponses, + V2SessionPromptResponses, + V2SessionWaitResponses, VcsDiffResponses, VcsGetResponses, WorktreeCreateErrors, @@ -244,111 +254,6 @@ class HeyApiRegistry { } } -export class Config extends HeyApiClient { - /** - * Get global configuration - * - * Retrieve the current global OpenCode configuration settings and preferences. - */ - public get(options?: Options) { - return (options?.client ?? this.client).get({ - url: "/global/config", - ...options, - }) - } - - /** - * Update global configuration - * - * Update global OpenCode configuration settings and preferences. - */ - public update( - parameters?: { - config?: Config3 - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) - return (options?.client ?? this.client).patch({ - url: "/global/config", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } -} - -export class Global extends HeyApiClient { - /** - * Get health - * - * Get health information about the OpenCode server. - */ - public health(options?: Options) { - return (options?.client ?? this.client).get({ - url: "/global/health", - ...options, - }) - } - - /** - * Get global events - * - * Subscribe to global events from the OpenCode system using server-sent events. - */ - public event(options?: Options) { - return (options?.client ?? this.client).sse.get({ - url: "/global/event", - ...options, - }) - } - - /** - * Dispose instance - * - * Clean up and dispose all OpenCode instances, releasing all resources. - */ - public dispose(options?: Options) { - return (options?.client ?? this.client).post({ - url: "/global/dispose", - ...options, - }) - } - - /** - * Upgrade opencode - * - * Upgrade opencode to the specified version or latest if not specified. - */ - public upgrade( - parameters?: { - target?: string - }, - options?: Options, - ) { - const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) - return (options?.client ?? this.client).post({ - url: "/global/upgrade", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - private _config?: Config - get config(): Config { - return (this._config ??= new Config({ client: this.client })) - } -} - export class Auth extends HeyApiClient { /** * Remove auth credentials @@ -512,13 +417,118 @@ export class App extends HeyApiClient { } } -export class Adapter extends HeyApiClient { +export class Config extends HeyApiClient { /** - * List workspace adapters + * Get global configuration * - * List all available workspace adapters for the current project. + * Retrieve the current global OpenCode configuration settings and preferences. */ - public list( + public get(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/config", + ...options, + }) + } + + /** + * Update global configuration + * + * Update global OpenCode configuration settings and preferences. + */ + public update( + parameters?: { + config?: Config3 + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }]) + return (options?.client ?? this.client).patch({ + url: "/global/config", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Global extends HeyApiClient { + /** + * Get health + * + * Get health information about the OpenCode server. + */ + public health(options?: Options) { + return (options?.client ?? this.client).get({ + url: "/global/health", + ...options, + }) + } + + /** + * Get global events + * + * Subscribe to global events from the OpenCode system using server-sent events. + */ + public event(options?: Options) { + return (options?.client ?? this.client).sse.get({ + url: "/global/event", + ...options, + }) + } + + /** + * Dispose instance + * + * Clean up and dispose all OpenCode instances, releasing all resources. + */ + public dispose(options?: Options) { + return (options?.client ?? this.client).post({ + url: "/global/dispose", + ...options, + }) + } + + /** + * Upgrade opencode + * + * Upgrade opencode to the specified version or latest if not specified. + */ + public upgrade( + parameters?: { + target?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "body", key: "target" }] }]) + return (options?.client ?? this.client).post({ + url: "/global/upgrade", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + private _config?: Config + get config(): Config { + return (this._config ??= new Config({ client: this.client })) + } +} + +export class Event extends HeyApiClient { + /** + * Subscribe to events + * + * Get events + */ + public subscribe( parameters?: { directory?: string workspace?: string @@ -536,21 +546,21 @@ export class Adapter extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace/adapter", + return (options?.client ?? this.client).sse.get({ + url: "/event", ...options, ...params, }) } } -export class Workspace extends HeyApiClient { +export class Config2 extends HeyApiClient { /** - * List workspaces + * Get configuration * - * List all workspaces. + * Retrieve the current OpenCode configuration settings and preferences. */ - public list( + public get( parameters?: { directory?: string workspace?: string @@ -568,26 +578,23 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace", + return (options?.client ?? this.client).get({ + url: "/config", ...options, ...params, }) } /** - * Create workspace + * Update configuration * - * Create a workspace for the current project. + * Update OpenCode configuration settings and preferences. */ - public create( + public update( parameters?: { directory?: string workspace?: string - id?: string - type?: string - branch?: string | null - extra?: unknown | null + config?: Config3 }, options?: Options, ) { @@ -598,20 +605,13 @@ export class Workspace extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "id" }, - { in: "body", key: "type" }, - { in: "body", key: "branch" }, - { in: "body", key: "extra" }, + { key: "config", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post< - ExperimentalWorkspaceCreateResponses, - ExperimentalWorkspaceCreateErrors, - ThrowOnError - >({ - url: "/experimental/workspace", + return (options?.client ?? this.client).patch({ + url: "/config", ...options, ...params, headers: { @@ -623,11 +623,11 @@ export class Workspace extends HeyApiClient { } /** - * Workspace status + * List config providers * - * Get connection status for workspaces in the current project. + * Get a list of all configured AI providers and their default models. */ - public status( + public providers( parameters?: { directory?: string workspace?: string @@ -645,96 +645,12 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace/status", - ...options, - ...params, - }) - } - - /** - * Remove workspace - * - * Remove an existing workspace. - */ - public remove( - parameters: { - id: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "id" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete< - ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceRemoveErrors, - ThrowOnError - >({ - url: "/experimental/workspace/{id}", - ...options, - ...params, - }) - } - - /** - * Restore session into workspace - * - * Replay a session's sync events into the target workspace in batches. - */ - public sessionRestore( - parameters: { - id: string - directory?: string - workspace?: string - sessionID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "id" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "sessionID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ExperimentalWorkspaceSessionRestoreResponses, - ExperimentalWorkspaceSessionRestoreErrors, - ThrowOnError - >({ - url: "/experimental/workspace/{id}/session-restore", + return (options?.client ?? this.client).get({ + url: "/config/providers", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } - - private _adapter?: Adapter - get adapter(): Adapter { - return (this._adapter ??= new Adapter({ client: this.client })) - } } export class Console extends HeyApiClient { @@ -914,33 +830,11 @@ export class Resource extends HeyApiClient { } } -export class Experimental extends HeyApiClient { - private _workspace?: Workspace - get workspace(): Workspace { - return (this._workspace ??= new Workspace({ client: this.client })) - } - - private _console?: Console - get console(): Console { - return (this._console ??= new Console({ client: this.client })) - } - - private _session?: Session - get session(): Session { - return (this._session ??= new Session({ client: this.client })) - } - - private _resource?: Resource - get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) - } -} - -export class Project extends HeyApiClient { +export class Adapter extends HeyApiClient { /** - * List all projects + * List workspace adapters * - * Get a list of projects that have been opened with OpenCode. + * List all available workspace adapters for the current project. */ public list( parameters?: { @@ -960,19 +854,21 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/project", + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/adapter", ...options, ...params, }) } +} +export class Workspace extends HeyApiClient { /** - * Get current project + * List workspaces * - * Retrieve the currently active project that OpenCode is working with. + * List all workspaces. */ - public current( + public list( parameters?: { directory?: string workspace?: string @@ -990,22 +886,26 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/project/current", + return (options?.client ?? this.client).get({ + url: "/experimental/workspace", ...options, ...params, }) } /** - * Initialize git repository + * Create workspace * - * Create a git repository for the current project and return the refreshed project info. + * Create a workspace for the current project. */ - public initGit( + public create( parameters?: { directory?: string workspace?: string + id?: string + type?: string + branch?: string | null + extra?: unknown | null }, options?: Options, ) { @@ -1016,39 +916,39 @@ export class Project extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "id" }, + { in: "body", key: "type" }, + { in: "body", key: "branch" }, + { in: "body", key: "extra" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/project/git/init", + return (options?.client ?? this.client).post< + ExperimentalWorkspaceCreateResponses, + ExperimentalWorkspaceCreateErrors, + ThrowOnError + >({ + url: "/experimental/workspace", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } /** - * Update project + * Workspace status * - * Update project properties such as name, icon, and commands. + * Get connection status for workspaces in the current project. */ - public update( - parameters: { - projectID: string + public status( + parameters?: { directory?: string workspace?: string - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } }, options?: Options, ) { @@ -1057,37 +957,27 @@ export class Project extends HeyApiClient { [ { args: [ - { in: "path", key: "projectID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "icon" }, - { in: "body", key: "commands" }, ], }, ], ) - return (options?.client ?? this.client).patch({ - url: "/project/{projectID}", + return (options?.client ?? this.client).get({ + url: "/experimental/workspace/status", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } -} -export class Pty extends HeyApiClient { /** - * List available shells + * Remove workspace * - * Get a list of available shells on the system. + * Remove an existing workspace. */ - public shells( - parameters?: { + public remove( + parameters: { + id: string directory?: string workspace?: string }, @@ -1098,28 +988,35 @@ export class Pty extends HeyApiClient { [ { args: [ + { in: "path", key: "id" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/pty/shells", + return (options?.client ?? this.client).delete< + ExperimentalWorkspaceRemoveResponses, + ExperimentalWorkspaceRemoveErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}", ...options, ...params, }) } /** - * List PTY sessions + * Restore session into workspace * - * Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode. + * Replay a session's sync events into the target workspace in batches. */ - public list( - parameters?: { + public sessionRestore( + parameters: { + id: string directory?: string workspace?: string + sessionID?: string }, options?: Options, ) { @@ -1128,76 +1025,70 @@ export class Pty extends HeyApiClient { [ { args: [ + { in: "path", key: "id" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/pty", + return (options?.client ?? this.client).post< + ExperimentalWorkspaceSessionRestoreResponses, + ExperimentalWorkspaceSessionRestoreErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}/session-restore", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } - /** - * Create PTY session - * - * Create a new pseudo-terminal (PTY) session for running shell commands and processes. - */ - public create( - parameters?: { - directory?: string - workspace?: string - command?: string - args?: Array - cwd?: string - title?: string - env?: { - [key: string]: string - } - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "command" }, - { in: "body", key: "args" }, - { in: "body", key: "cwd" }, - { in: "body", key: "title" }, - { in: "body", key: "env" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/pty", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) + private _adapter?: Adapter + get adapter(): Adapter { + return (this._adapter ??= new Adapter({ client: this.client })) + } +} + +export class Experimental extends HeyApiClient { + private _console?: Console + get console(): Console { + return (this._console ??= new Console({ client: this.client })) + } + + private _session?: Session + get session(): Session { + return (this._session ??= new Session({ client: this.client })) + } + + private _resource?: Resource + get resource(): Resource { + return (this._resource ??= new Resource({ client: this.client })) + } + + private _workspace?: Workspace + get workspace(): Workspace { + return (this._workspace ??= new Workspace({ client: this.client })) } +} +export class Tool extends HeyApiClient { /** - * Remove PTY session + * List tools * - * Remove and terminate a specific pseudo-terminal (PTY) session. + * Get a list of available tools with their JSON schema parameters for a specific provider and model combination. */ - public remove( + public list( parameters: { - ptyID: string directory?: string workspace?: string + provider: string + model: string }, options?: Options, ) { @@ -1206,28 +1097,28 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "provider" }, + { in: "query", key: "model" }, ], }, ], ) - return (options?.client ?? this.client).delete({ - url: "/pty/{ptyID}", + return (options?.client ?? this.client).get({ + url: "/experimental/tool", ...options, ...params, }) } /** - * Get PTY session + * List tool IDs * - * Retrieve detailed information about a specific pseudo-terminal (PTY) session. + * Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. */ - public get( - parameters: { - ptyID: string + public ids( + parameters?: { directory?: string workspace?: string }, @@ -1238,35 +1129,31 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/pty/{ptyID}", + return (options?.client ?? this.client).get({ + url: "/experimental/tool/ids", ...options, ...params, }) } +} +export class Worktree extends HeyApiClient { /** - * Update PTY session + * Remove worktree * - * Update properties of an existing pseudo-terminal (PTY) session. + * Remove a git worktree and delete its branch. */ - public update( - parameters: { - ptyID: string + public remove( + parameters?: { directory?: string workspace?: string - title?: string - size?: { - rows: number - cols: number - } + worktreeRemoveInput?: WorktreeRemoveInput }, options?: Options, ) { @@ -1275,17 +1162,15 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "title" }, - { in: "body", key: "size" }, + { key: "worktreeRemoveInput", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).put({ - url: "/pty/{ptyID}", + return (options?.client ?? this.client).delete({ + url: "/experimental/worktree", ...options, ...params, headers: { @@ -1297,13 +1182,12 @@ export class Pty extends HeyApiClient { } /** - * Connect to PTY session + * List worktrees * - * Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time. + * List all sandbox worktrees for the current project. */ - public connect( - parameters: { - ptyID: string + public list( + parameters?: { directory?: string workspace?: string }, @@ -1314,31 +1198,29 @@ export class Pty extends HeyApiClient { [ { args: [ - { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/pty/{ptyID}/connect", + return (options?.client ?? this.client).get({ + url: "/experimental/worktree", ...options, ...params, }) } -} -export class Config2 extends HeyApiClient { /** - * Get configuration + * Create worktree * - * Retrieve the current OpenCode configuration settings and preferences. + * Create a new git worktree for the current project and run any configured startup scripts. */ - public get( + public create( parameters?: { directory?: string workspace?: string + worktreeCreateInput?: WorktreeCreateInput }, options?: Options, ) { @@ -1349,27 +1231,33 @@ export class Config2 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { key: "worktreeCreateInput", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/config", + return (options?.client ?? this.client).post({ + url: "/experimental/worktree", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } /** - * Update configuration + * Reset worktree * - * Update OpenCode configuration settings and preferences. + * Reset a worktree branch to the primary default branch. */ - public update( + public reset( parameters?: { directory?: string workspace?: string - config?: Config3 + worktreeResetInput?: WorktreeResetInput }, options?: Options, ) { @@ -1380,13 +1268,13 @@ export class Config2 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "config", map: "body" }, + { key: "worktreeResetInput", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).patch({ - url: "/config", + return (options?.client ?? this.client).post({ + url: "/experimental/worktree/reset", ...options, ...params, headers: { @@ -1396,16 +1284,19 @@ export class Config2 extends HeyApiClient { }, }) } +} +export class Find extends HeyApiClient { /** - * List config providers + * Find text * - * Get a list of all configured AI providers and their default models. + * Search for text patterns across files in the project using ripgrep. */ - public providers( - parameters?: { + public text( + parameters: { directory?: string workspace?: string + pattern: string }, options?: Options, ) { @@ -1416,28 +1307,31 @@ export class Config2 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "pattern" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/config/providers", + return (options?.client ?? this.client).get({ + url: "/find", ...options, ...params, }) } -} -export class Tool extends HeyApiClient { /** - * List tool IDs + * Find files * - * Get a list of all available tool IDs, including both built-in tools and dynamically registered tools. + * Search for files or directories by name or pattern in the project directory. */ - public ids( - parameters?: { + public files( + parameters: { directory?: string workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number }, options?: Options, ) { @@ -1448,28 +1342,31 @@ export class Tool extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "query" }, + { in: "query", key: "dirs" }, + { in: "query", key: "type" }, + { in: "query", key: "limit" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/tool/ids", + return (options?.client ?? this.client).get({ + url: "/find/file", ...options, ...params, }) } /** - * List tools + * Find symbols * - * Get a list of available tools with their JSON schema parameters for a specific provider and model combination. + * Search for workspace symbols like functions, classes, and variables using LSP. */ - public list( + public symbols( parameters: { directory?: string workspace?: string - provider: string - model: string + query: string }, options?: Options, ) { @@ -1480,31 +1377,30 @@ export class Tool extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "provider" }, - { in: "query", key: "model" }, + { in: "query", key: "query" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/tool", + return (options?.client ?? this.client).get({ + url: "/find/symbol", ...options, ...params, }) } } -export class Worktree extends HeyApiClient { +export class File extends HeyApiClient { /** - * Remove worktree + * List files * - * Remove a git worktree and delete its branch. + * List files and directories in a specified path. */ - public remove( - parameters?: { + public list( + parameters: { directory?: string workspace?: string - worktreeRemoveInput?: WorktreeRemoveInput + path: string }, options?: Options, ) { @@ -1515,32 +1411,28 @@ export class Worktree extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeRemoveInput", map: "body" }, + { in: "query", key: "path" }, ], }, ], ) - return (options?.client ?? this.client).delete({ - url: "/experimental/worktree", + return (options?.client ?? this.client).get({ + url: "/file", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } /** - * List worktrees + * Read file * - * List all sandbox worktrees for the current project. + * Read the content of a specified file. */ - public list( - parameters?: { + public read( + parameters: { directory?: string workspace?: string + path: string }, options?: Options, ) { @@ -1551,27 +1443,27 @@ export class Worktree extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "path" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/experimental/worktree", + return (options?.client ?? this.client).get({ + url: "/file/content", ...options, ...params, }) } /** - * Create worktree + * Get file status * - * Create a new git worktree for the current project and run any configured startup scripts. + * Get the git status of all files in the project. */ - public create( + public status( parameters?: { directory?: string workspace?: string - worktreeCreateInput?: WorktreeCreateInput }, options?: Options, ) { @@ -1582,33 +1474,28 @@ export class Worktree extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeCreateInput", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree", + return (options?.client ?? this.client).get({ + url: "/file/status", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } +} +export class Instance extends HeyApiClient { /** - * Reset worktree + * Dispose instance * - * Reset a worktree branch to the primary default branch. + * Clean up and dispose the current OpenCode instance, releasing all resources. */ - public reset( + public dispose( parameters?: { directory?: string workspace?: string - worktreeResetInput?: WorktreeResetInput }, options?: Options, ) { @@ -1619,40 +1506,28 @@ export class Worktree extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "worktreeResetInput", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/experimental/worktree/reset", + return (options?.client ?? this.client).post({ + url: "/instance/dispose", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } } -export class Session2 extends HeyApiClient { +export class Path extends HeyApiClient { /** - * List sessions + * Get paths * - * Get a list of all OpenCode sessions, sorted by most recently updated. + * Retrieve the current working directory and related path information for the OpenCode instance. */ - public list( + public get( parameters?: { directory?: string workspace?: string - scope?: "project" - path?: string - roots?: boolean | "true" | "false" - start?: number - search?: string - limit?: number }, options?: Options, ) { @@ -1663,36 +1538,28 @@ export class Session2 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "scope" }, - { in: "query", key: "path" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, - { in: "query", key: "search" }, - { in: "query", key: "limit" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/session", + return (options?.client ?? this.client).get({ + url: "/path", ...options, ...params, }) } +} +export class Vcs extends HeyApiClient { /** - * Create session + * Get VCS info * - * Create a new OpenCode session for interacting with AI assistants and managing conversations. + * Retrieve version control system (VCS) information for the current project, such as git branch. */ - public create( + public get( parameters?: { directory?: string workspace?: string - parentID?: string - title?: string - permission?: PermissionRuleset - workspaceID?: string }, options?: Options, ) { @@ -1703,35 +1570,27 @@ export class Session2 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "parentID" }, - { in: "body", key: "title" }, - { in: "body", key: "permission" }, - { in: "body", key: "workspaceID" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session", + return (options?.client ?? this.client).get({ + url: "/vcs", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } /** - * Get session status + * Get VCS diff * - * Retrieve the current status of all sessions, including active, idle, and completed states. + * Retrieve the current git diff for the working tree or against the default branch. */ - public status( - parameters?: { + public diff( + parameters: { directory?: string workspace?: string + mode: "git" | "branch" }, options?: Options, ) { @@ -1742,25 +1601,27 @@ export class Session2 extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "mode" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/session/status", + return (options?.client ?? this.client).get({ + url: "/vcs/diff", ...options, ...params, }) } +} +export class Command extends HeyApiClient { /** - * Delete session + * List commands * - * Delete a session and permanently remove all associated data, including messages and history. + * Get a list of all available commands in the OpenCode system. */ - public delete( - parameters: { - sessionID: string + public list( + parameters?: { directory?: string workspace?: string }, @@ -1771,28 +1632,28 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}", + return (options?.client ?? this.client).get({ + url: "/command", ...options, ...params, }) } +} +export class Lsp extends HeyApiClient { /** - * Get session + * Get LSP status * - * Retrieve detailed information about a specific OpenCode session. + * Get LSP server status */ - public get( - parameters: { - sessionID: string + public status( + parameters?: { directory?: string workspace?: string }, @@ -1803,35 +1664,30 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}", + return (options?.client ?? this.client).get({ + url: "/lsp", ...options, ...params, }) } +} +export class Formatter extends HeyApiClient { /** - * Update session + * Get formatter status * - * Update properties of an existing session, such as title or other metadata. + * Get formatter status */ - public update( - parameters: { - sessionID: string + public status( + parameters?: { directory?: string workspace?: string - title?: string - permission?: PermissionRuleset - time?: { - archived?: number - } }, options?: Options, ) { @@ -1840,36 +1696,29 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "title" }, - { in: "body", key: "permission" }, - { in: "body", key: "time" }, ], }, ], ) - return (options?.client ?? this.client).patch({ - url: "/session/{sessionID}", + return (options?.client ?? this.client).get({ + url: "/formatter", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } +} +export class Auth2 extends HeyApiClient { /** - * Get session children + * Remove MCP OAuth * - * Retrieve all child sessions that were forked from the specified parent session. + * Remove OAuth credentials for an MCP server. */ - public children( + public remove( parameters: { - sessionID: string + name: string directory?: string workspace?: string }, @@ -1880,28 +1729,28 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "name" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/children", + return (options?.client ?? this.client).delete({ + url: "/mcp/{name}/auth", ...options, ...params, }) } /** - * Get session todos + * Start MCP OAuth * - * Retrieve the todo list associated with a specific session, showing tasks and action items. + * Start OAuth authentication flow for a Model Context Protocol (MCP) server. */ - public todo( + public start( parameters: { - sessionID: string + name: string directory?: string workspace?: string }, @@ -1912,33 +1761,31 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "name" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/todo", + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth", ...options, ...params, }) } /** - * Initialize session + * Complete MCP OAuth * - * Analyze the current application and create an AGENTS.md file with project-specific agent configurations. + * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. */ - public init( + public callback( parameters: { - sessionID: string + name: string directory?: string workspace?: string - modelID?: string - providerID?: string - messageID?: string + code?: string }, options?: Options, ) { @@ -1947,18 +1794,16 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "name" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "modelID" }, - { in: "body", key: "providerID" }, - { in: "body", key: "messageID" }, + { in: "body", key: "code" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/init", + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/auth/callback", ...options, ...params, headers: { @@ -1970,16 +1815,15 @@ export class Session2 extends HeyApiClient { } /** - * Fork session + * Authenticate MCP OAuth * - * Create a new session by forking an existing session at a specific message point. + * Start OAuth flow and wait for callback (opens browser). */ - public fork( + public authenticate( parameters: { - sessionID: string + name: string directory?: string workspace?: string - messageID?: string }, options?: Options, ) { @@ -1988,34 +1832,31 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "name" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/fork", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, + return (options?.client ?? this.client).post( + { + url: "/mcp/{name}/auth/authenticate", + ...options, + ...params, }, - }) + ) } +} +export class Mcp extends HeyApiClient { /** - * Abort session + * Get MCP status * - * Abort an active session and stop any ongoing AI processing or command execution. + * Get the status of all Model Context Protocol (MCP) servers. */ - public abort( - parameters: { - sessionID: string + public status( + parameters?: { directory?: string workspace?: string }, @@ -2026,28 +1867,64 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/abort", + return (options?.client ?? this.client).get({ + url: "/mcp", ...options, ...params, }) } /** - * Unshare session + * Add MCP server * - * Remove the shareable link for a session, making it private again. + * Dynamically add a new Model Context Protocol (MCP) server to the system. */ - public unshare( + public add( + parameters?: { + directory?: string + workspace?: string + name?: string + config?: McpLocalConfig | McpRemoteConfig + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "config" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Connect an MCP server. + */ + public connect( parameters: { - sessionID: string + name: string directory?: string workspace?: string }, @@ -2058,28 +1935,316 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "name" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}/share", + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/connect", ...options, ...params, }) } /** - * Share session + * Disconnect an MCP server. + */ + public disconnect( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/mcp/{name}/disconnect", + ...options, + ...params, + }) + } + + private _auth?: Auth2 + get auth(): Auth2 { + return (this._auth ??= new Auth2({ client: this.client })) + } +} + +export class Project extends HeyApiClient { + /** + * List all projects * - * Create a shareable link for a session, allowing others to view the conversation. + * Get a list of projects that have been opened with OpenCode. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/project", + ...options, + ...params, + }) + } + + /** + * Get current project + * + * Retrieve the currently active project that OpenCode is working with. + */ + public current( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/project/current", + ...options, + ...params, + }) + } + + /** + * Initialize git repository + * + * Create a git repository for the current project and return the refreshed project info. + */ + public initGit( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/project/git/init", + ...options, + ...params, + }) + } + + /** + * Update project + * + * Update project properties such as name, icon, and commands. + */ + public update( + parameters: { + projectID: string + directory?: string + workspace?: string + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "projectID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "name" }, + { in: "body", key: "icon" }, + { in: "body", key: "commands" }, + ], + }, + ], + ) + return (options?.client ?? this.client).patch({ + url: "/project/{projectID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Pty extends HeyApiClient { + /** + * List available shells + * + * Get a list of available shells on the system. + */ + public shells( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/pty/shells", + ...options, + ...params, + }) + } + + /** + * List PTY sessions + * + * Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/pty", + ...options, + ...params, + }) + } + + /** + * Create PTY session + * + * Create a new pseudo-terminal (PTY) session for running shell commands and processes. + */ + public create( + parameters?: { + directory?: string + workspace?: string + command?: string + args?: Array + cwd?: string + title?: string + env?: { + [key: string]: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "command" }, + { in: "body", key: "args" }, + { in: "body", key: "cwd" }, + { in: "body", key: "title" }, + { in: "body", key: "env" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/pty", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Remove PTY session + * + * Remove and terminate a specific pseudo-terminal (PTY) session. */ - public share( + public remove( parameters: { - sessionID: string + ptyID: string directory?: string workspace?: string }, @@ -2090,31 +2255,30 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/share", + return (options?.client ?? this.client).delete({ + url: "/pty/{ptyID}", ...options, ...params, }) } /** - * Get message diff + * Get PTY session * - * Get the file changes (diff) that resulted from a specific user message in the session. + * Retrieve detailed information about a specific pseudo-terminal (PTY) session. */ - public diff( + public get( parameters: { - sessionID: string + ptyID: string directory?: string workspace?: string - messageID?: string }, options?: Options, ) { @@ -2123,34 +2287,35 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "messageID" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/diff", + return (options?.client ?? this.client).get({ + url: "/pty/{ptyID}", ...options, ...params, }) } /** - * Summarize session + * Update PTY session * - * Generate a concise summary of the session using AI compaction to preserve key information. + * Update properties of an existing pseudo-terminal (PTY) session. */ - public summarize( + public update( parameters: { - sessionID: string + ptyID: string directory?: string workspace?: string - providerID?: string - modelID?: string - auto?: boolean + title?: string + size?: { + rows: number + cols: number + } }, options?: Options, ) { @@ -2159,18 +2324,17 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "providerID" }, - { in: "body", key: "modelID" }, - { in: "body", key: "auto" }, + { in: "body", key: "title" }, + { in: "body", key: "size" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/summarize", + return (options?.client ?? this.client).put({ + url: "/pty/{ptyID}", ...options, ...params, headers: { @@ -2182,17 +2346,15 @@ export class Session2 extends HeyApiClient { } /** - * Get session messages + * Connect to PTY session * - * Retrieve all messages in a session, including user prompts and AI responses. + * Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time. */ - public messages( + public connect( parameters: { - sessionID: string + ptyID: string directory?: string workspace?: string - limit?: number - before?: string }, options?: Options, ) { @@ -2201,46 +2363,31 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "ptyID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "limit" }, - { in: "query", key: "before" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/message", + return (options?.client ?? this.client).get({ + url: "/pty/{ptyID}/connect", ...options, ...params, }) } +} +export class Question extends HeyApiClient { /** - * Send message + * List pending questions * - * Create and send a new message to a session, streaming the AI response. + * Get all pending question requests across all sessions. */ - public prompt( - parameters: { - sessionID: string + public list( + parameters?: { directory?: string workspace?: string - messageID?: string - model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean - tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts?: Array }, options?: Options, ) { @@ -2249,45 +2396,30 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "model" }, - { in: "body", key: "agent" }, - { in: "body", key: "noReply" }, - { in: "body", key: "tools" }, - { in: "body", key: "format" }, - { in: "body", key: "system" }, - { in: "body", key: "variant" }, - { in: "body", key: "parts" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/message", + return (options?.client ?? this.client).get({ + url: "/question", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } /** - * Delete message + * Reply to question request * - * Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message. + * Provide answers to a question request from the AI assistant. */ - public deleteMessage( + public reply( parameters: { - sessionID: string - messageID: string + requestID: string directory?: string workspace?: string + answers?: Array }, options?: Options, ) { @@ -2296,34 +2428,34 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, + { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "answers" }, ], }, ], ) - return (options?.client ?? this.client).delete< - SessionDeleteMessageResponses, - SessionDeleteMessageErrors, - ThrowOnError - >({ - url: "/session/{sessionID}/message/{messageID}", + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reply", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } /** - * Get message + * Reject question request * - * Retrieve a specific message from a session by its message ID. + * Reject a question request from the AI assistant. */ - public message( + public reject( parameters: { - sessionID: string - messageID: string + requestID: string directory?: string workspace?: string }, @@ -2334,45 +2466,31 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, + { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/session/{sessionID}/message/{messageID}", + return (options?.client ?? this.client).post({ + url: "/question/{requestID}/reject", ...options, ...params, }) } +} +export class Permission extends HeyApiClient { /** - * Send async message + * List pending permissions * - * Create and send a new message to a session asynchronously, starting the session if needed and returning immediately. + * Get all pending permission requests across all sessions. */ - public promptAsync( - parameters: { - sessionID: string + public list( + parameters?: { directory?: string workspace?: string - messageID?: string - model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean - tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts?: Array }, options?: Options, ) { @@ -2381,58 +2499,31 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "model" }, - { in: "body", key: "agent" }, - { in: "body", key: "noReply" }, - { in: "body", key: "tools" }, - { in: "body", key: "format" }, - { in: "body", key: "system" }, - { in: "body", key: "variant" }, - { in: "body", key: "parts" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/prompt_async", + return (options?.client ?? this.client).get({ + url: "/permission", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } /** - * Send command + * Respond to permission request * - * Send a new command to a session for execution by the AI assistant. + * Approve or deny a permission request from the AI assistant. */ - public command( + public reply( parameters: { - sessionID: string + requestID: string directory?: string workspace?: string - messageID?: string - agent?: string - model?: string - arguments?: string - command?: string - variant?: string - parts?: Array<{ - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource - }> + reply?: "once" | "always" | "reject" + message?: string }, options?: Options, ) { @@ -2441,22 +2532,17 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "agent" }, - { in: "body", key: "model" }, - { in: "body", key: "arguments" }, - { in: "body", key: "command" }, - { in: "body", key: "variant" }, - { in: "body", key: "parts" }, + { in: "body", key: "reply" }, + { in: "body", key: "message" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/command", + return (options?.client ?? this.client).post({ + url: "/permission/{requestID}/reply", ...options, ...params, headers: { @@ -2468,22 +2554,19 @@ export class Session2 extends HeyApiClient { } /** - * Run shell command + * Respond to permission * - * Execute a shell command within the session context and return the AI's response. + * Approve or deny a permission request from the AI assistant. + * + * @deprecated */ - public shell( + public respond( parameters: { sessionID: string + permissionID: string directory?: string workspace?: string - messageID?: string - agent?: string - model?: { - providerID: string - modelID: string - } - command?: string + response?: "once" | "always" | "reject" }, options?: Options, ) { @@ -2493,18 +2576,16 @@ export class Session2 extends HeyApiClient { { args: [ { in: "path", key: "sessionID" }, + { in: "path", key: "permissionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "agent" }, - { in: "body", key: "model" }, - { in: "body", key: "command" }, + { in: "body", key: "response" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/shell", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/permissions/{permissionID}", ...options, ...params, headers: { @@ -2514,19 +2595,23 @@ export class Session2 extends HeyApiClient { }, }) } +} +export class Oauth extends HeyApiClient { /** - * Revert message + * Start OAuth authorization * - * Revert a specific message in a session, undoing its effects and restoring the previous state. + * Start the OAuth authorization flow for a provider. */ - public revert( + public authorize( parameters: { - sessionID: string + providerID: string directory?: string workspace?: string - messageID?: string - partID?: string + method?: number + inputs?: { + [key: string]: string + } }, options?: Options, ) { @@ -2535,17 +2620,21 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "providerID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "messageID" }, - { in: "body", key: "partID" }, + { in: "body", key: "method" }, + { in: "body", key: "inputs" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/revert", + return (options?.client ?? this.client).post< + ProviderOauthAuthorizeResponses, + ProviderOauthAuthorizeErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/authorize", ...options, ...params, headers: { @@ -2557,15 +2646,17 @@ export class Session2 extends HeyApiClient { } /** - * Restore reverted messages + * Handle OAuth callback * - * Restore all previously reverted messages in a session. + * Handle the OAuth callback from a provider after user authorization. */ - public unrevert( + public callback( parameters: { - sessionID: string + providerID: string directory?: string workspace?: string + method?: number + code?: string }, options?: Options, ) { @@ -2574,30 +2665,40 @@ export class Session2 extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, + { in: "path", key: "providerID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "method" }, + { in: "body", key: "code" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/unrevert", + return (options?.client ?? this.client).post< + ProviderOauthCallbackResponses, + ProviderOauthCallbackErrors, + ThrowOnError + >({ + url: "/provider/{providerID}/oauth/callback", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } } -export class Part extends HeyApiClient { +export class Provider extends HeyApiClient { /** - * Delete a part from a message + * List providers + * + * Get a list of all available AI providers, including both available and connected ones. */ - public delete( - parameters: { - sessionID: string - messageID: string - partID: string + public list( + parameters?: { directory?: string workspace?: string }, @@ -2608,33 +2709,28 @@ export class Part extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, - { in: "path", key: "partID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).delete({ - url: "/session/{sessionID}/message/{messageID}/part/{partID}", + return (options?.client ?? this.client).get({ + url: "/provider", ...options, ...params, }) } /** - * Update a part in a message + * Get provider auth methods + * + * Retrieve available authentication methods for all AI providers. */ - public update( - parameters: { - sessionID: string - messageID: string - partID: string + public auth( + parameters?: { directory?: string workspace?: string - part?: Part2 }, options?: Options, ) { @@ -2643,44 +2739,41 @@ export class Part extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "messageID" }, - { in: "path", key: "partID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "part", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).patch({ - url: "/session/{sessionID}/message/{messageID}/part/{partID}", + return (options?.client ?? this.client).get({ + url: "/provider/auth", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } + + private _oauth?: Oauth + get oauth(): Oauth { + return (this._oauth ??= new Oauth({ client: this.client })) + } } -export class Permission extends HeyApiClient { +export class Session2 extends HeyApiClient { /** - * Respond to permission - * - * Approve or deny a permission request from the AI assistant. + * List sessions * - * @deprecated + * Get a list of all OpenCode sessions, sorted by most recently updated. */ - public respond( - parameters: { - sessionID: string - permissionID: string + public list( + parameters?: { directory?: string workspace?: string - response?: "once" | "always" | "reject" + scope?: "project" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + limit?: number }, options?: Options, ) { @@ -2689,39 +2782,44 @@ export class Permission extends HeyApiClient { [ { args: [ - { in: "path", key: "sessionID" }, - { in: "path", key: "permissionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "response" }, + { in: "query", key: "scope" }, + { in: "query", key: "path" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "search" }, + { in: "query", key: "limit" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/session/{sessionID}/permissions/{permissionID}", + return (options?.client ?? this.client).get({ + url: "/session", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } /** - * Respond to permission request + * Create session * - * Approve or deny a permission request from the AI assistant. + * Create a new OpenCode session for interacting with AI assistants and managing conversations. */ - public reply( - parameters: { - requestID: string + public create( + parameters?: { directory?: string workspace?: string - reply?: "once" | "always" | "reject" - message?: string + parentID?: string + title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + permission?: PermissionRuleset + workspaceID?: string }, options?: Options, ) { @@ -2730,17 +2828,20 @@ export class Permission extends HeyApiClient { [ { args: [ - { in: "path", key: "requestID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "reply" }, - { in: "body", key: "message" }, + { in: "body", key: "parentID" }, + { in: "body", key: "title" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, + { in: "body", key: "permission" }, + { in: "body", key: "workspaceID" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/permission/{requestID}/reply", + return (options?.client ?? this.client).post({ + url: "/session", ...options, ...params, headers: { @@ -2752,11 +2853,11 @@ export class Permission extends HeyApiClient { } /** - * List pending permissions + * Get session status * - * Get all pending permission requests across all sessions. + * Retrieve the current status of all sessions, including active, idle, and completed states. */ - public list( + public status( parameters?: { directory?: string workspace?: string @@ -2774,22 +2875,21 @@ export class Permission extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/permission", + return (options?.client ?? this.client).get({ + url: "/session/status", ...options, ...params, }) } -} -export class Question extends HeyApiClient { /** - * List pending questions + * Delete session * - * Get all pending question requests across all sessions. + * Delete a session and permanently remove all associated data, including messages and history. */ - public list( - parameters?: { + public delete( + parameters: { + sessionID: string directory?: string workspace?: string }, @@ -2800,30 +2900,30 @@ export class Question extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/question", + return (options?.client ?? this.client).delete({ + url: "/session/{sessionID}", ...options, ...params, }) } /** - * Reply to question request + * Get session * - * Provide answers to a question request from the AI assistant. + * Retrieve detailed information about a specific OpenCode session. */ - public reply( + public get( parameters: { - requestID: string + sessionID: string directory?: string workspace?: string - answers?: Array }, options?: Options, ) { @@ -2832,36 +2932,35 @@ export class Question extends HeyApiClient { [ { args: [ - { in: "path", key: "requestID" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "answers" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reply", + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } /** - * Reject question request + * Update session * - * Reject a question request from the AI assistant. + * Update properties of an existing session, such as title or other metadata. */ - public reject( + public update( parameters: { - requestID: string + sessionID: string directory?: string workspace?: string + title?: string + permission?: PermissionRuleset + time?: { + archived?: number + } }, options?: Options, ) { @@ -2870,36 +2969,38 @@ export class Question extends HeyApiClient { [ { args: [ - { in: "path", key: "requestID" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "title" }, + { in: "body", key: "permission" }, + { in: "body", key: "time" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/question/{requestID}/reject", + return (options?.client ?? this.client).patch({ + url: "/session/{sessionID}", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } -} -export class Oauth extends HeyApiClient { /** - * OAuth authorize + * Get session children * - * Initiate OAuth authorization for a specific AI provider to get an authorization URL. + * Retrieve all child sessions that were forked from the specified parent session. */ - public authorize( + public children( parameters: { - providerID: string + sessionID: string directory?: string workspace?: string - method?: number - inputs?: { - [key: string]: string - } }, options?: Options, ) { @@ -2908,43 +3009,30 @@ export class Oauth extends HeyApiClient { [ { args: [ - { in: "path", key: "providerID" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "inputs" }, ], }, ], ) - return (options?.client ?? this.client).post< - ProviderOauthAuthorizeResponses, - ProviderOauthAuthorizeErrors, - ThrowOnError - >({ - url: "/provider/{providerID}/oauth/authorize", + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/children", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } /** - * OAuth callback + * Get session todos * - * Handle the OAuth callback from a provider after user authorization. + * Retrieve the todo list associated with a specific session, showing tasks and action items. */ - public callback( + public todo( parameters: { - providerID: string + sessionID: string directory?: string workspace?: string - method?: number - code?: string }, options?: Options, ) { @@ -2953,42 +3041,31 @@ export class Oauth extends HeyApiClient { [ { args: [ - { in: "path", key: "providerID" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "method" }, - { in: "body", key: "code" }, ], }, ], ) - return (options?.client ?? this.client).post< - ProviderOauthCallbackResponses, - ProviderOauthCallbackErrors, - ThrowOnError - >({ - url: "/provider/{providerID}/oauth/callback", + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/todo", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } -} -export class Provider extends HeyApiClient { /** - * List providers + * Get message diff * - * Get a list of all available AI providers, including both available and connected ones. + * Get the file changes (diff) that resulted from a specific user message in the session. */ - public list( - parameters?: { + public diff( + parameters: { + sessionID: string directory?: string workspace?: string + messageID?: string }, options?: Options, ) { @@ -2997,28 +3074,33 @@ export class Provider extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "messageID" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/provider", + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/diff", ...options, ...params, }) } /** - * Get provider auth methods + * Get session messages * - * Retrieve available authentication methods for all AI providers. + * Retrieve all messages in a session, including user prompts and AI responses. */ - public auth( - parameters?: { + public messages( + parameters: { + sessionID: string directory?: string workspace?: string + limit?: number + before?: string }, options?: Options, ) { @@ -3027,38 +3109,46 @@ export class Provider extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "query", key: "limit" }, + { in: "query", key: "before" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/provider/auth", + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/message", ...options, ...params, }) } - private _oauth?: Oauth - get oauth(): Oauth { - return (this._oauth ??= new Oauth({ client: this.client })) - } -} - -export class History extends HeyApiClient { /** - * List sync events + * Send message * - * List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history. + * Create and send a new message to a session, streaming the AI response. */ - public list( - parameters?: { + public prompt( + parameters: { + sessionID: string directory?: string workspace?: string - body?: { - [key: string]: number + messageID?: string + model?: { + providerID: string + modelID: string + } + agent?: string + noReply?: boolean + tools?: { + [key: string]: boolean } + format?: OutputFormat + system?: string + variant?: string + parts?: Array }, options?: Options, ) { @@ -3067,15 +3157,24 @@ export class History extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "body", map: "body" }, + { in: "body", key: "messageID" }, + { in: "body", key: "model" }, + { in: "body", key: "agent" }, + { in: "body", key: "noReply" }, + { in: "body", key: "tools" }, + { in: "body", key: "format" }, + { in: "body", key: "system" }, + { in: "body", key: "variant" }, + { in: "body", key: "parts" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/sync/history", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/message", ...options, ...params, headers: { @@ -3085,16 +3184,16 @@ export class History extends HeyApiClient { }, }) } -} -export class Sync extends HeyApiClient { /** - * Start workspace sync + * Delete message * - * Start sync loops for workspaces in the current project that have active sessions. + * Permanently delete a specific message and all of its parts from a session without reverting file changes. */ - public start( - parameters?: { + public deleteMessage( + parameters: { + sessionID: string + messageID: string directory?: string workspace?: string }, @@ -3105,38 +3204,36 @@ export class Sync extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/sync/start", + return (options?.client ?? this.client).delete< + SessionDeleteMessageResponses, + SessionDeleteMessageErrors, + ThrowOnError + >({ + url: "/session/{sessionID}/message/{messageID}", ...options, ...params, }) } /** - * Replay sync events + * Get message * - * Validate and replay a complete sync event history. + * Retrieve a specific message from a session by its message ID. */ - public replay( - parameters?: { - query_directory?: string + public message( + parameters: { + sessionID: string + messageID: string + directory?: string workspace?: string - body_directory?: string - events?: Array<{ - id: string - aggregateID: string - seq: number - type: string - data: { - [key: string]: unknown - } - }> }, options?: Options, ) { @@ -3145,51 +3242,32 @@ export class Sync extends HeyApiClient { [ { args: [ - { - in: "query", - key: "query_directory", - map: "directory", - }, + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { - in: "body", - key: "body_directory", - map: "directory", - }, - { in: "body", key: "events" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/sync/replay", + return (options?.client ?? this.client).get({ + url: "/session/{sessionID}/message/{messageID}", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } - private _history?: History - get history(): History { - return (this._history ??= new History({ client: this.client })) - } -} - -export class Find extends HeyApiClient { /** - * Find text + * Fork session * - * Search for text patterns across files in the project using ripgrep. + * Create a new session by forking an existing session at a specific message point. */ - public text( + public fork( parameters: { + sessionID: string directory?: string workspace?: string - pattern: string + messageID?: string }, options?: Options, ) { @@ -3198,33 +3276,36 @@ export class Find extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "pattern" }, + { in: "body", key: "messageID" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/find", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/fork", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } /** - * Find files + * Abort session * - * Search for files or directories by name or pattern in the project directory. + * Abort an active session and stop any ongoing AI processing or command execution. */ - public files( + public abort( parameters: { + sessionID: string directory?: string workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number }, options?: Options, ) { @@ -3233,33 +3314,33 @@ export class Find extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "query" }, - { in: "query", key: "dirs" }, - { in: "query", key: "type" }, - { in: "query", key: "limit" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/find/file", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/abort", ...options, ...params, }) } /** - * Find symbols + * Initialize session * - * Search for workspace symbols like functions, classes, and variables using LSP. + * Analyze the current application and create an AGENTS.md file with project-specific agent configurations. */ - public symbols( + public init( parameters: { + sessionID: string directory?: string workspace?: string - query: string + modelID?: string + providerID?: string + messageID?: string }, options?: Options, ) { @@ -3268,32 +3349,38 @@ export class Find extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "query" }, + { in: "body", key: "modelID" }, + { in: "body", key: "providerID" }, + { in: "body", key: "messageID" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/find/symbol", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/init", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } -} -export class File extends HeyApiClient { /** - * List files + * Unshare session * - * List files and directories in a specified path. + * Remove the shareable link for a session, making it private again. */ - public list( + public unshare( parameters: { + sessionID: string directory?: string workspace?: string - path: string }, options?: Options, ) { @@ -3302,30 +3389,30 @@ export class File extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "path" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/file", + return (options?.client ?? this.client).delete({ + url: "/session/{sessionID}/share", ...options, ...params, }) } /** - * Read file + * Share session * - * Read the content of a specified file. + * Create a shareable link for a session, allowing others to view the conversation. */ - public read( + public share( parameters: { + sessionID: string directory?: string workspace?: string - path: string }, options?: Options, ) { @@ -3334,29 +3421,33 @@ export class File extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "path" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/file/content", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/share", ...options, ...params, }) } /** - * Get file status + * Summarize session * - * Get the git status of all files in the project. + * Generate a concise summary of the session using AI compaction to preserve key information. */ - public status( - parameters?: { + public summarize( + parameters: { + sessionID: string directory?: string workspace?: string + providerID?: string + modelID?: string + auto?: boolean }, options?: Options, ) { @@ -3365,30 +3456,52 @@ export class File extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "providerID" }, + { in: "body", key: "modelID" }, + { in: "body", key: "auto" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/file/status", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/summarize", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } -} -export class Event extends HeyApiClient { /** - * Subscribe to events + * Send async message * - * Get events + * Create and send a new message to a session asynchronously, starting the session if needed and returning immediately. */ - public subscribe( - parameters?: { + public promptAsync( + parameters: { + sessionID: string directory?: string workspace?: string + messageID?: string + model?: { + providerID: string + modelID: string + } + agent?: string + noReply?: boolean + tools?: { + [key: string]: boolean + } + format?: OutputFormat + system?: string + variant?: string + parts?: Array }, options?: Options, ) { @@ -3397,31 +3510,58 @@ export class Event extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + { in: "body", key: "model" }, + { in: "body", key: "agent" }, + { in: "body", key: "noReply" }, + { in: "body", key: "tools" }, + { in: "body", key: "format" }, + { in: "body", key: "system" }, + { in: "body", key: "variant" }, + { in: "body", key: "parts" }, ], }, ], ) - return (options?.client ?? this.client).sse.get({ - url: "/event", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/prompt_async", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } -} -export class Auth2 extends HeyApiClient { /** - * Remove MCP OAuth + * Send command * - * Remove OAuth credentials for an MCP server + * Send a new command to a session for execution by the AI assistant. */ - public remove( + public command( parameters: { - name: string + sessionID: string directory?: string workspace?: string + messageID?: string + agent?: string + model?: string + arguments?: string + command?: string + variant?: string + parts?: Array<{ + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource + }> }, options?: Options, ) { @@ -3430,30 +3570,49 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, + { in: "body", key: "arguments" }, + { in: "body", key: "command" }, + { in: "body", key: "variant" }, + { in: "body", key: "parts" }, ], }, ], ) - return (options?.client ?? this.client).delete({ - url: "/mcp/{name}/auth", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/command", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } /** - * Start MCP OAuth + * Run shell command * - * Start OAuth authentication flow for a Model Context Protocol (MCP) server. + * Execute a shell command within the session context and return the AI's response. */ - public start( + public shell( parameters: { - name: string + sessionID: string directory?: string workspace?: string + messageID?: string + agent?: string + model?: { + providerID: string + modelID: string + } + command?: string }, options?: Options, ) { @@ -3462,31 +3621,41 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "messageID" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, + { in: "body", key: "command" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/shell", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } /** - * Complete MCP OAuth + * Revert message * - * Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code. + * Revert a specific message in a session, undoing its effects and restoring the previous state. */ - public callback( + public revert( parameters: { - name: string + sessionID: string directory?: string workspace?: string - code?: string + messageID?: string + partID?: string }, options?: Options, ) { @@ -3495,16 +3664,17 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "code" }, + { in: "body", key: "messageID" }, + { in: "body", key: "partID" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/auth/callback", + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/revert", ...options, ...params, headers: { @@ -3516,13 +3686,13 @@ export class Auth2 extends HeyApiClient { } /** - * Authenticate MCP OAuth + * Restore reverted messages * - * Start OAuth flow and wait for callback (opens browser) + * Restore all previously reverted messages in a session. */ - public authenticate( + public unrevert( parameters: { - name: string + sessionID: string directory?: string workspace?: string }, @@ -3533,31 +3703,30 @@ export class Auth2 extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post( - { - url: "/mcp/{name}/auth/authenticate", - ...options, - ...params, - }, - ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/unrevert", + ...options, + ...params, + }) } } -export class Mcp extends HeyApiClient { +export class Part extends HeyApiClient { /** - * Get MCP status - * - * Get the status of all Model Context Protocol (MCP) servers. + * Delete a part from a message. */ - public status( - parameters?: { + public delete( + parameters: { + sessionID: string + messageID: string + partID: string directory?: string workspace?: string }, @@ -3568,30 +3737,33 @@ export class Mcp extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + { in: "path", key: "partID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/mcp", + return (options?.client ?? this.client).delete({ + url: "/session/{sessionID}/message/{messageID}/part/{partID}", ...options, ...params, }) } /** - * Add MCP server - * - * Dynamically add a new Model Context Protocol (MCP) server to the system. + * Update a part in a message. */ - public add( - parameters?: { + public update( + parameters: { + sessionID: string + messageID: string + partID: string directory?: string workspace?: string - name?: string - config?: McpLocalConfig | McpRemoteConfig + part?: Part2 }, options?: Options, ) { @@ -3600,16 +3772,18 @@ export class Mcp extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "messageID" }, + { in: "path", key: "partID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "name" }, - { in: "body", key: "config" }, + { key: "part", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/mcp", + return (options?.client ?? this.client).patch({ + url: "/session/{sessionID}/message/{messageID}/part/{partID}", ...options, ...params, headers: { @@ -3619,15 +3793,21 @@ export class Mcp extends HeyApiClient { }, }) } +} +export class History extends HeyApiClient { /** - * Connect an MCP server + * List sync events + * + * List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history. */ - public connect( - parameters: { - name: string + public list( + parameters?: { directory?: string workspace?: string + body?: { + [key: string]: number + } }, options?: Options, ) { @@ -3636,26 +3816,34 @@ export class Mcp extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { key: "body", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/connect", + return (options?.client ?? this.client).post({ + url: "/sync/history", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } +} +export class Sync extends HeyApiClient { /** - * Disconnect an MCP server + * Start workspace sync + * + * Start sync loops for workspaces in the current project that have active sessions. */ - public disconnect( - parameters: { - name: string + public start( + parameters?: { directory?: string workspace?: string }, @@ -3666,36 +3854,38 @@ export class Mcp extends HeyApiClient { [ { args: [ - { in: "path", key: "name" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/mcp/{name}/disconnect", + return (options?.client ?? this.client).post({ + url: "/sync/start", ...options, ...params, }) } - private _auth?: Auth2 - get auth(): Auth2 { - return (this._auth ??= new Auth2({ client: this.client })) - } -} - -export class Control extends HeyApiClient { /** - * Get next TUI request + * Replay sync events * - * Retrieve the next TUI (Terminal User Interface) request from the queue for processing. + * Validate and replay a complete sync event history. */ - public next( + public replay( parameters?: { - directory?: string + query_directory?: string workspace?: string + body_directory?: string + events?: Array<{ + id: string + aggregateID: string + seq: number + type: string + data: { + [key: string]: unknown + } + }> }, options?: Options, ) { @@ -3704,29 +3894,50 @@ export class Control extends HeyApiClient { [ { args: [ - { in: "query", key: "directory" }, + { + in: "query", + key: "query_directory", + map: "directory", + }, { in: "query", key: "workspace" }, + { + in: "body", + key: "body_directory", + map: "directory", + }, + { in: "body", key: "events" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/tui/control/next", + return (options?.client ?? this.client).post({ + url: "/sync/replay", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } + private _history?: History + get history(): History { + return (this._history ??= new History({ client: this.client })) + } +} + +export class Session3 extends HeyApiClient { /** - * Submit TUI response + * List v2 sessions * - * Submit a response to the TUI request queue to complete a pending request. + * Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list. */ - public response( + public list( parameters?: { directory?: string workspace?: string - body?: unknown }, options?: Options, ) { @@ -3737,35 +3948,29 @@ export class Control extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "body", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/control/response", + return (options?.client ?? this.client).get({ + url: "/api/session", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } -} -export class Tui extends HeyApiClient { /** - * Append TUI prompt + * Send v2 message * - * Append prompt to the TUI + * Create a v2 session message and queue it for the agent loop. */ - public appendPrompt( - parameters?: { + public prompt( + parameters: { + sessionID: string directory?: string workspace?: string - text?: string + prompt?: Prompt + delivery?: SessionDelivery }, options?: Options, ) { @@ -3774,15 +3979,17 @@ export class Tui extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "text" }, + { in: "body", key: "prompt" }, + { in: "body", key: "delivery" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/append-prompt", + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/prompt", ...options, ...params, headers: { @@ -3794,12 +4001,13 @@ export class Tui extends HeyApiClient { } /** - * Open help dialog + * Compact v2 session * - * Open the help dialog in the TUI to display user assistance information. + * Compact a v2 session conversation. */ - public openHelp( - parameters?: { + public compact( + parameters: { + sessionID: string directory?: string workspace?: string }, @@ -3810,26 +4018,28 @@ export class Tui extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/open-help", + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/compact", ...options, ...params, }) } /** - * Open sessions dialog + * Wait for v2 session * - * Open the session dialog + * Wait for a v2 session agent loop to become idle. */ - public openSessions( - parameters?: { + public wait( + parameters: { + sessionID: string directory?: string workspace?: string }, @@ -3840,26 +4050,28 @@ export class Tui extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/open-sessions", + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/wait", ...options, ...params, }) } /** - * Open themes dialog + * Get v2 session context * - * Open the theme dialog + * Retrieve the active context messages for a v2 session (all messages after the last compaction). */ - public openThemes( - parameters?: { + public context( + parameters: { + sessionID: string directory?: string workspace?: string }, @@ -3870,26 +4082,28 @@ export class Tui extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/open-themes", + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/context", ...options, ...params, }) } /** - * Open models dialog + * Get v2 session messages * - * Open the model dialog + * Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline. */ - public openModels( - parameters?: { + public messages( + parameters: { + sessionID: string directory?: string workspace?: string }, @@ -3900,25 +4114,35 @@ export class Tui extends HeyApiClient { [ { args: [ + { in: "path", key: "sessionID" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/open-models", + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/message", ...options, ...params, }) } +} + +export class V2 extends HeyApiClient { + private _session?: Session3 + get session(): Session3 { + return (this._session ??= new Session3({ client: this.client })) + } +} +export class Control extends HeyApiClient { /** - * Submit TUI prompt + * Get next TUI request * - * Submit the prompt + * Retrieve the next TUI request from the queue for processing. */ - public submitPrompt( + public next( parameters?: { directory?: string workspace?: string @@ -3936,22 +4160,23 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/submit-prompt", + return (options?.client ?? this.client).get({ + url: "/tui/control/next", ...options, ...params, }) } /** - * Clear TUI prompt + * Submit TUI response * - * Clear the prompt + * Submit a response to the TUI request queue to complete a pending request. */ - public clearPrompt( + public response( parameters?: { directory?: string workspace?: string + body?: unknown }, options?: Options, ) { @@ -3962,27 +4187,35 @@ export class Tui extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { key: "body", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/clear-prompt", + return (options?.client ?? this.client).post({ + url: "/tui/control/response", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } +} +export class Tui extends HeyApiClient { /** - * Execute TUI command + * Append TUI prompt * - * Execute a TUI command (e.g. agent_cycle) + * Append prompt to the TUI. */ - public executeCommand( + public appendPrompt( parameters?: { directory?: string workspace?: string - command?: string + text?: string }, options?: Options, ) { @@ -3993,13 +4226,13 @@ export class Tui extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "command" }, + { in: "body", key: "text" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/execute-command", + return (options?.client ?? this.client).post({ + url: "/tui/append-prompt", ...options, ...params, headers: { @@ -4011,18 +4244,14 @@ export class Tui extends HeyApiClient { } /** - * Show TUI toast + * Open help dialog * - * Show a toast notification in the TUI + * Open the help dialog in the TUI to display user assistance information. */ - public showToast( + public openHelp( parameters?: { directory?: string workspace?: string - title?: string - message?: string - variant?: "info" | "success" | "warning" | "error" - duration?: number }, options?: Options, ) { @@ -4033,36 +4262,26 @@ export class Tui extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "title" }, - { in: "body", key: "message" }, - { in: "body", key: "variant" }, - { in: "body", key: "duration" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/show-toast", + return (options?.client ?? this.client).post({ + url: "/tui/open-help", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } /** - * Publish TUI event + * Open sessions dialog * - * Publish a TUI event + * Open the session dialog. */ - public publish( + public openSessions( parameters?: { directory?: string workspace?: string - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect }, options?: Options, ) { @@ -4073,33 +4292,26 @@ export class Tui extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { key: "body", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/publish", + return (options?.client ?? this.client).post({ + url: "/tui/open-sessions", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } /** - * Select session + * Open themes dialog * - * Navigate the TUI to display the specified session. + * Open the theme dialog. */ - public selectSession( + public openThemes( parameters?: { directory?: string workspace?: string - sessionID?: string }, options?: Options, ) { @@ -4110,36 +4322,23 @@ export class Tui extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "body", key: "sessionID" }, ], }, ], ) - return (options?.client ?? this.client).post({ - url: "/tui/select-session", + return (options?.client ?? this.client).post({ + url: "/tui/open-themes", ...options, ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, }) } - private _control?: Control - get control(): Control { - return (this._control ??= new Control({ client: this.client })) - } -} - -export class Instance extends HeyApiClient { /** - * Dispose instance + * Open models dialog * - * Clean up and dispose the current OpenCode instance, releasing all resources. + * Open the model dialog. */ - public dispose( + public openModels( parameters?: { directory?: string workspace?: string @@ -4157,21 +4356,19 @@ export class Instance extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ - url: "/instance/dispose", + return (options?.client ?? this.client).post({ + url: "/tui/open-models", ...options, ...params, }) } -} -export class Path extends HeyApiClient { /** - * Get paths + * Submit TUI prompt * - * Retrieve the current working directory and related path information for the OpenCode instance. + * Submit the prompt. */ - public get( + public submitPrompt( parameters?: { directory?: string workspace?: string @@ -4189,21 +4386,19 @@ export class Path extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/path", + return (options?.client ?? this.client).post({ + url: "/tui/submit-prompt", ...options, ...params, }) } -} -export class Vcs extends HeyApiClient { /** - * Get VCS info + * Clear TUI prompt * - * Retrieve version control system (VCS) information for the current project, such as git branch. + * Clear the prompt. */ - public get( + public clearPrompt( parameters?: { directory?: string workspace?: string @@ -4221,23 +4416,23 @@ export class Vcs extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ - url: "/vcs", + return (options?.client ?? this.client).post({ + url: "/tui/clear-prompt", ...options, ...params, }) } /** - * Get VCS diff + * Execute TUI command * - * Retrieve the current git diff for the working tree or against the default branch. + * Execute a TUI command. */ - public diff( - parameters: { + public executeCommand( + parameters?: { directory?: string workspace?: string - mode: "git" | "branch" + command?: string }, options?: Options, ) { @@ -4248,29 +4443,36 @@ export class Vcs extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, - { in: "query", key: "mode" }, + { in: "body", key: "command" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/vcs/diff", + return (options?.client ?? this.client).post({ + url: "/tui/execute-command", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } -} -export class Command extends HeyApiClient { /** - * List commands + * Show TUI toast * - * Get a list of all available commands in the OpenCode system. + * Show a toast notification in the TUI. */ - public list( + public showToast( parameters?: { directory?: string workspace?: string + title?: string + message?: string + variant?: "info" | "success" | "warning" | "error" + duration?: number }, options?: Options, ) { @@ -4281,28 +4483,36 @@ export class Command extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "title" }, + { in: "body", key: "message" }, + { in: "body", key: "variant" }, + { in: "body", key: "duration" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/command", + return (options?.client ?? this.client).post({ + url: "/tui/show-toast", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } -} -export class Lsp extends HeyApiClient { /** - * Get LSP status + * Publish TUI event * - * Get LSP server status + * Publish a TUI event. */ - public status( + public publish( parameters?: { directory?: string workspace?: string + body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 }, options?: Options, ) { @@ -4313,28 +4523,33 @@ export class Lsp extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { key: "body", map: "body" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/lsp", + return (options?.client ?? this.client).post({ + url: "/tui/publish", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } -} -export class Formatter extends HeyApiClient { /** - * Get formatter status + * Select session * - * Get formatter status + * Navigate the TUI to display the specified session. */ - public status( + public selectSession( parameters?: { directory?: string workspace?: string + sessionID?: string }, options?: Options, ) { @@ -4345,16 +4560,27 @@ export class Formatter extends HeyApiClient { args: [ { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, ], }, ], ) - return (options?.client ?? this.client).get({ - url: "/formatter", + return (options?.client ?? this.client).post({ + url: "/tui/select-session", ...options, ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, }) } + + private _control?: Control + get control(): Control { + return (this._control ??= new Control({ client: this.client })) + } } export class OpencodeClient extends HeyApiClient { @@ -4365,11 +4591,6 @@ export class OpencodeClient extends HeyApiClient { OpencodeClient.__registry.set(this, args?.key) } - private _global?: Global - get global(): Global { - return (this._global ??= new Global({ client: this.client })) - } - private _auth?: Auth get auth(): Auth { return (this._auth ??= new Auth({ client: this.client })) @@ -4380,19 +4601,14 @@ export class OpencodeClient extends HeyApiClient { return (this._app ??= new App({ client: this.client })) } - private _experimental?: Experimental - get experimental(): Experimental { - return (this._experimental ??= new Experimental({ client: this.client })) - } - - private _project?: Project - get project(): Project { - return (this._project ??= new Project({ client: this.client })) + private _global?: Global + get global(): Global { + return (this._global ??= new Global({ client: this.client })) } - private _pty?: Pty - get pty(): Pty { - return (this._pty ??= new Pty({ client: this.client })) + private _event?: Event + get event(): Event { + return (this._event ??= new Event({ client: this.client })) } private _config?: Config2 @@ -4400,6 +4616,11 @@ export class OpencodeClient extends HeyApiClient { return (this._config ??= new Config2({ client: this.client })) } + private _experimental?: Experimental + get experimental(): Experimental { + return (this._experimental ??= new Experimental({ client: this.client })) + } + private _tool?: Tool get tool(): Tool { return (this._tool ??= new Tool({ client: this.client })) @@ -4410,36 +4631,6 @@ export class OpencodeClient extends HeyApiClient { return (this._worktree ??= new Worktree({ client: this.client })) } - private _session?: Session2 - get session(): Session2 { - return (this._session ??= new Session2({ client: this.client })) - } - - private _part?: Part - get part(): Part { - return (this._part ??= new Part({ client: this.client })) - } - - private _permission?: Permission - get permission(): Permission { - return (this._permission ??= new Permission({ client: this.client })) - } - - private _question?: Question - get question(): Question { - return (this._question ??= new Question({ client: this.client })) - } - - private _provider?: Provider - get provider(): Provider { - return (this._provider ??= new Provider({ client: this.client })) - } - - private _sync?: Sync - get sync(): Sync { - return (this._sync ??= new Sync({ client: this.client })) - } - private _find?: Find get find(): Find { return (this._find ??= new Find({ client: this.client })) @@ -4450,21 +4641,6 @@ export class OpencodeClient extends HeyApiClient { return (this._file ??= new File({ client: this.client })) } - private _event?: Event - get event(): Event { - return (this._event ??= new Event({ client: this.client })) - } - - private _mcp?: Mcp - get mcp(): Mcp { - return (this._mcp ??= new Mcp({ client: this.client })) - } - - private _tui?: Tui - get tui(): Tui { - return (this._tui ??= new Tui({ client: this.client })) - } - private _instance?: Instance get instance(): Instance { return (this._instance ??= new Instance({ client: this.client })) @@ -4494,4 +4670,59 @@ export class OpencodeClient extends HeyApiClient { get formatter(): Formatter { return (this._formatter ??= new Formatter({ client: this.client })) } + + private _mcp?: Mcp + get mcp(): Mcp { + return (this._mcp ??= new Mcp({ client: this.client })) + } + + private _project?: Project + get project(): Project { + return (this._project ??= new Project({ client: this.client })) + } + + private _pty?: Pty + get pty(): Pty { + return (this._pty ??= new Pty({ client: this.client })) + } + + private _question?: Question + get question(): Question { + return (this._question ??= new Question({ client: this.client })) + } + + private _permission?: Permission + get permission(): Permission { + return (this._permission ??= new Permission({ client: this.client })) + } + + private _provider?: Provider + get provider(): Provider { + return (this._provider ??= new Provider({ client: this.client })) + } + + private _session?: Session2 + get session(): Session2 { + return (this._session ??= new Session2({ client: this.client })) + } + + private _part?: Part + get part(): Part { + return (this._part ??= new Part({ client: this.client })) + } + + private _sync?: Sync + get sync(): Sync { + return (this._sync ??= new Sync({ client: this.client })) + } + + private _v2?: V2 + get v2(): V2 { + return (this._v2 ??= new V2({ client: this.client })) + } + + private _tui?: Tui + get tui(): Tui { + return (this._tui ??= new Tui({ 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 31bd40ab4f..caa3d4c767 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4,53 +4,104 @@ export type ClientOptions = { baseUrl: `${string}://${string}` | (string & {}) } -export type EventServerInstanceDisposed = { - type: "server.instance.disposed" - properties: { - directory: string - } -} - -export type EventFileEdited = { - type: "file.edited" - properties: { - file: string - } -} +export type Event = + | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated + | EventLspClientDiagnostics + | EventLspUpdated + | EventMessagePartDelta + | EventPermissionAsked + | EventPermissionReplied + | EventSessionDiff + | EventSessionError + | EventInstallationUpdated + | EventInstallationUpdateAvailable + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventTodoUpdated + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow1 + | EventTuiSessionSelect + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed + | EventCommandExecuted + | EventProjectUpdated + | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventWorktreeReady + | EventWorktreeFailed + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolError + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } +export type OAuth = { + type: "oauth" + refresh: string + access: string + expires: number + accountId?: string + enterpriseUrl?: string } -export type EventLspClientDiagnostics = { - type: "lsp.client.diagnostics" - properties: { - serverID: string - path: string +export type ApiAuth = { + type: "api" + key: string + metadata?: { + [key: string]: string } } -export type EventLspUpdated = { - type: "lsp.updated" - properties: { - [key: string]: unknown - } +export type WellKnownAuth = { + type: "wellknown" + key: string + token: string } -export type EventMessagePartDelta = { - type: "message.part.delta" - properties: { - sessionID: string - messageID: string - partID: string - field: string - delta: string - } -} +export type Auth = OAuth | ApiAuth | WellKnownAuth export type PermissionRequest = { id: string @@ -67,20 +118,6 @@ export type PermissionRequest = { } } -export type EventPermissionAsked = { - type: "permission.asked" - properties: PermissionRequest -} - -export type EventPermissionReplied = { - type: "permission.replied" - properties: { - sessionID: string - requestID: string - reply: "once" | "always" | "reject" - } -} - export type SnapshotFileDiff = { file: string patch: string @@ -89,14 +126,6 @@ export type SnapshotFileDiff = { status?: "added" | "deleted" | "modified" } -export type EventSessionDiff = { - type: "session.diff" - properties: { - sessionID: string - diff: Array - } -} - export type ProviderAuthError = { name: "ProviderAuthError" data: { @@ -158,35 +187,6 @@ export type ApiError = { } } -export type EventSessionError = { - type: "session.error" - properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError - } -} - -export type EventInstallationUpdated = { - type: "installation.updated" - properties: { - version: string - } -} - -export type EventInstallationUpdateAvailable = { - type: "installation.update-available" - properties: { - version: string - } -} - export type QuestionOption = { /** * Display text (1-5 words, concise) @@ -211,13 +211,7 @@ export type QuestionInfo = { * Available choices */ options: Array - /** - * Allow selecting multiple choices - */ multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ custom?: boolean } @@ -236,11 +230,6 @@ export type QuestionRequest = { tool?: QuestionTool } -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest -} - export type QuestionAnswer = Array export type QuestionReplied = { @@ -249,21 +238,11 @@ export type QuestionReplied = { answers: Array } -export type EventQuestionReplied = { - type: "question.replied" - properties: QuestionReplied -} - export type QuestionRejected = { sessionID: string requestID: string } -export type EventQuestionRejected = { - type: "question.rejected" - properties: QuestionRejected -} - export type Todo = { /** * Brief description of the task @@ -279,14 +258,6 @@ export type Todo = { priority: string } -export type EventTodoUpdated = { - type: "todo.updated" - properties: { - sessionID: string - todos: Array - } -} - export type SessionStatus = | { type: "idle" @@ -301,29 +272,8 @@ export type SessionStatus = type: "busy" } -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - -export type EventSessionCompacted = { - type: "session.compacted" - properties: { - sessionID: string - } -} - export type EventTuiPromptAppend = { + id: string type: "tui.prompt.append" properties: { text: string @@ -331,6 +281,7 @@ export type EventTuiPromptAppend = { } export type EventTuiCommandExecute = { + id: string type: "tui.command.execute" properties: { command: @@ -355,19 +306,18 @@ export type EventTuiCommandExecute = { } export type EventTuiToastShow = { + id: string type: "tui.toast.show" properties: { title?: string message: string variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ duration?: number } } export type EventTuiSessionSelect = { + id: string type: "tui.session.select" properties: { /** @@ -377,31 +327,6 @@ export type EventTuiSessionSelect = { } } -export type EventMcpToolsChanged = { - type: "mcp.tools.changed" - properties: { - server: string - } -} - -export type EventMcpBrowserOpenFailed = { - type: "mcp.browser.open.failed" - properties: { - mcpName: string - url: string - } -} - -export type EventCommandExecuted = { - type: "command.executed" - properties: { - name: string - sessionID: string - arguments: string - messageID: string - } -} - export type Project = { id: string worktree: string @@ -426,106 +351,18 @@ export type Project = { sandboxes: Array } -export type EventProjectUpdated = { - type: "project.updated" - properties: Project +export type Pty = { + id: string + title: string + command: string + args: Array + cwd: string + status: "running" | "exited" + pid: number } -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" - properties: { - branch?: string - } -} - -export type EventWorkspaceReady = { - type: "workspace.ready" - properties: { - name: string - } -} - -export type EventWorkspaceFailed = { - type: "workspace.failed" - properties: { - message: string - } -} - -export type EventWorkspaceRestore = { - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - -export type EventWorkspaceStatus = { - type: "workspace.status" - properties: { - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - } -} - -export type EventWorktreeReady = { - type: "worktree.ready" - properties: { - name: string - branch: string - } -} - -export type EventWorktreeFailed = { - type: "worktree.failed" - properties: { - message: string - } -} - -export type Pty = { - id: string - title: string - command: string - args: Array - cwd: string - status: "running" | "exited" - pid: number -} - -export type EventPtyCreated = { - type: "pty.created" - properties: { - info: Pty - } -} - -export type EventPtyUpdated = { - type: "pty.updated" - properties: { - info: Pty - } -} - -export type EventPtyExited = { - type: "pty.exited" - properties: { - id: string - exitCode: number - } -} - -export type EventPtyDeleted = { - type: "pty.deleted" - properties: { - id: string - } -} - -export type OutputFormatText = { - type: "text" +export type OutputFormatText = { + type: "text" } export type JsonSchema = { @@ -609,22 +446,6 @@ export type AssistantMessage = { export type Message = UserMessage | AssistantMessage -export type EventMessageUpdated = { - type: "message.updated" - properties: { - sessionID: string - info: Message - } -} - -export type EventMessageRemoved = { - type: "message.removed" - properties: { - sessionID: string - messageID: string - } -} - export type TextPart = { id: string sessionID: string @@ -888,24 +709,6 @@ export type Part = | RetryPart | CompactionPart -export type EventMessagePartUpdated = { - type: "message.part.updated" - properties: { - sessionID: string - part: Part - time: number - } -} - -export type EventMessagePartRemoved = { - type: "message.part.removed" - properties: { - sessionID: string - messageID: string - partID: string - } -} - export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -934,6 +737,12 @@ export type Session = { url: string } title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } version: string time: { created: number @@ -950,261 +759,146 @@ export type Session = { } } -export type EventSessionCreated = { - type: "session.created" - properties: { - sessionID: string - info: Session - } +export type Prompt = { + text: string + files?: Array + agents?: Array } -export type EventSessionUpdated = { - type: "session.updated" - properties: { - sessionID: string - info: Session - } +export type GlobalEvent = { + directory: string + project?: string + workspace?: string + payload: + | EventServerInstanceDisposed + | EventFileEdited + | EventFileWatcherUpdated + | EventLspClientDiagnostics + | EventLspUpdated + | EventMessagePartDelta + | EventPermissionAsked + | EventPermissionReplied + | EventSessionDiff + | EventSessionError + | EventInstallationUpdated + | EventInstallationUpdateAvailable + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventTodoUpdated + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventTuiPromptAppend + | EventTuiCommandExecute + | EventTuiToastShow + | EventTuiSessionSelect + | EventMcpToolsChanged + | EventMcpBrowserOpenFailed + | EventCommandExecuted + | EventProjectUpdated + | EventVcsBranchUpdated + | EventWorkspaceReady + | EventWorkspaceFailed + | EventWorkspaceRestore + | EventWorkspaceStatus + | EventWorktreeReady + | EventWorktreeFailed + | EventPtyCreated + | EventPtyUpdated + | EventPtyExited + | EventPtyDeleted + | EventMessageUpdated + | EventMessageRemoved + | EventMessagePartUpdated + | EventMessagePartRemoved + | EventSessionCreated + | EventSessionUpdated + | EventSessionDeleted + | EventSessionNextAgentSwitched + | EventSessionNextModelSwitched + | EventSessionNextPrompted + | EventSessionNextSynthetic + | EventSessionNextShellStarted + | EventSessionNextShellEnded + | EventSessionNextStepStarted + | EventSessionNextStepEnded + | EventSessionNextTextStarted + | EventSessionNextTextDelta + | EventSessionNextTextEnded + | EventSessionNextReasoningStarted + | EventSessionNextReasoningDelta + | EventSessionNextReasoningEnded + | EventSessionNextToolInputStarted + | EventSessionNextToolInputDelta + | EventSessionNextToolInputEnded + | EventSessionNextToolCalled + | EventSessionNextToolProgress + | EventSessionNextToolSuccess + | EventSessionNextToolError + | EventSessionNextRetried + | EventSessionNextCompactionStarted + | EventSessionNextCompactionDelta + | EventSessionNextCompactionEnded + | EventServerConnected + | EventGlobalDisposed + | SyncEventMessageUpdated + | SyncEventMessageRemoved + | SyncEventMessagePartUpdated + | SyncEventMessagePartRemoved + | SyncEventSessionCreated + | SyncEventSessionUpdated + | SyncEventSessionDeleted + | SyncEventSessionNextAgentSwitched + | SyncEventSessionNextModelSwitched + | SyncEventSessionNextPrompted + | SyncEventSessionNextSynthetic + | SyncEventSessionNextShellStarted + | SyncEventSessionNextShellEnded + | SyncEventSessionNextStepStarted + | SyncEventSessionNextStepEnded + | SyncEventSessionNextTextStarted + | SyncEventSessionNextTextDelta + | SyncEventSessionNextTextEnded + | SyncEventSessionNextReasoningStarted + | SyncEventSessionNextReasoningDelta + | SyncEventSessionNextReasoningEnded + | SyncEventSessionNextToolInputStarted + | SyncEventSessionNextToolInputDelta + | SyncEventSessionNextToolInputEnded + | SyncEventSessionNextToolCalled + | SyncEventSessionNextToolProgress + | SyncEventSessionNextToolSuccess + | SyncEventSessionNextToolError + | SyncEventSessionNextRetried + | SyncEventSessionNextCompactionStarted + | SyncEventSessionNextCompactionDelta + | SyncEventSessionNextCompactionEnded } -export type EventSessionDeleted = { - type: "session.deleted" - properties: { - sessionID: string - info: Session - } -} +/** + * Log level + */ +export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } +/** + * Server configuration for opencode serve and web commands + */ +export type ServerConfig = { + port?: number + hostname?: string + mdns?: boolean + mdnsDomain?: string + cors?: Array } -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} +export type PermissionActionConfig = "ask" | "allow" | "deny" -export type SyncEventMessageUpdated = { - type: "sync" - name: "message.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Message - } +export type PermissionObjectConfig = { + [key: string]: PermissionActionConfig } -export type SyncEventMessageRemoved = { - type: "sync" - name: "message.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - } -} - -export type SyncEventMessagePartUpdated = { - type: "sync" - name: "message.part.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - part: Part - time: number - } -} - -export type SyncEventMessagePartRemoved = { - type: "sync" - name: "message.part.removed.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - messageID: string - partID: string - } -} - -export type SyncEventSessionCreated = { - type: "sync" - name: "session.created.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session - } -} - -export type SyncEventSessionUpdated = { - type: "sync" - name: "session.updated.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: { - id?: string | null - slug?: string | null - projectID?: string | null - workspaceID?: string | null - directory?: string | null - path?: string | null - parentID?: string | null - summary?: { - additions: number - deletions: number - files: number - diffs?: Array - } | null - share?: { - url?: string | null - } - title?: string | null - version?: string | null - time?: { - created?: number | null - updated?: number | null - compacting?: number | null - archived?: number | null - } - permission?: PermissionRuleset | null - revert?: { - messageID: string - partID?: string - snapshot?: string - diff?: string - } | null - } - } -} - -export type SyncEventSessionDeleted = { - type: "sync" - name: "session.deleted.1" - id: string - seq: number - aggregateID: "sessionID" - data: { - sessionID: string - info: Session - } -} - -export type GlobalEvent = { - directory: string - project?: string - workspace?: string - payload: - | EventServerInstanceDisposed - | EventFileEdited - | EventFileWatcherUpdated - | EventLspClientDiagnostics - | EventLspUpdated - | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied - | EventSessionDiff - | EventSessionError - | EventInstallationUpdated - | EventInstallationUpdateAvailable - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventTodoUpdated - | EventSessionStatus - | EventSessionIdle - | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed - | EventCommandExecuted - | EventProjectUpdated - | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventWorktreeReady - | EventWorktreeFailed - | EventPtyCreated - | EventPtyUpdated - | EventPtyExited - | EventPtyDeleted - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted - | EventServerConnected - | EventGlobalDisposed - | SyncEventMessageUpdated - | SyncEventMessageRemoved - | SyncEventMessagePartUpdated - | SyncEventMessagePartRemoved - | SyncEventSessionCreated - | SyncEventSessionUpdated - | SyncEventSessionDeleted -} - -/** - * Log level - */ -export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR" - -/** - * Server configuration for opencode serve and web commands - */ -export type ServerConfig = { - /** - * Port to listen on - */ - port?: number - /** - * Hostname to listen on - */ - hostname?: string - /** - * Enable mDNS service discovery - */ - mdns?: boolean - /** - * Custom domain name for mDNS service (default: opencode.local) - */ - mdnsDomain?: string - /** - * Additional domains to allow for CORS - */ - cors?: Array -} - -export type PermissionActionConfig = "ask" | "allow" | "deny" - -export type PermissionObjectConfig = { - [key: string]: PermissionActionConfig -} - -export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig +export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig export type PermissionConfig = | PermissionActionConfig @@ -1229,28 +923,16 @@ export type PermissionConfig = export type AgentConfig = { model?: string - /** - * Default model variant for this agent (applies only when using the agent's configured model). - */ variant?: string temperature?: number top_p?: number prompt?: string - /** - * @deprecated Use 'permission' field instead - */ tools?: { [key: string]: boolean } disable?: boolean - /** - * Description of when to use the agent - */ description?: string mode?: "subagent" | "primary" | "all" - /** - * Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent) - */ hidden?: boolean options?: { [key: string]: unknown @@ -1259,13 +941,7 @@ export type AgentConfig = { * Hex color code (e.g., #FF5733) or theme color (e.g., primary) */ color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" - /** - * Maximum number of agentic iterations before forcing text-only response - */ steps?: number - /** - * @deprecated Use 'steps' field instead. - */ maxSteps?: number permission?: PermissionConfig [key: string]: @@ -1306,21 +982,12 @@ export type ProviderConfig = { options?: { apiKey?: string baseURL?: string - /** - * GitHub Enterprise URL for copilot authentication - */ enterpriseUrl?: string - /** - * Enable promptCacheKey for this provider (default false) - */ setCacheKey?: boolean /** * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. */ timeout?: number | false - /** - * Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. - */ chunkTimeout?: number [key: string]: unknown | string | boolean | number | false | number | undefined } @@ -1377,9 +1044,6 @@ export type ProviderConfig = { */ variants?: { [key: string]: { - /** - * Disable this variant for the model - */ disabled?: boolean [key: string]: unknown | boolean | undefined } @@ -1397,38 +1061,17 @@ export type McpLocalConfig = { * Command and arguments to run the MCP server */ command: Array - /** - * Environment variables to set when running the MCP server - */ environment?: { [key: string]: string } - /** - * Enable or disable the MCP server on startup - */ enabled?: boolean - /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. - */ timeout?: number } export type McpOAuthConfig = { - /** - * OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted. - */ clientId?: string - /** - * OAuth client secret (if required by the authorization server) - */ clientSecret?: string - /** - * OAuth scopes to request during authorization - */ scope?: string - /** - * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). - */ redirectUri?: string } @@ -1441,13 +1084,7 @@ export type McpRemoteConfig = { * URL of the remote MCP server */ url: string - /** - * Enable or disable the MCP server on startup - */ enabled?: boolean - /** - * Headers to send with the request - */ headers?: { [key: string]: string } @@ -1455,9 +1092,6 @@ export type McpRemoteConfig = { * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. */ oauth?: McpOAuthConfig | false - /** - * Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified. - */ timeout?: number } @@ -1467,19 +1101,10 @@ export type McpRemoteConfig = { export type LayoutConfig = "auto" | "stretch" export type Config = { - /** - * JSON schema reference for configuration validation - */ $schema?: string - /** - * Default shell to use for terminal and bash tool - */ shell?: string logLevel?: LogLevel server?: ServerConfig - /** - * Command configuration, see https://opencode.ai/docs/commands - */ command?: { [key: string]: { template: string @@ -1489,25 +1114,13 @@ export type Config = { subtask?: boolean } } - /** - * Additional skill folder paths - */ skills?: { - /** - * Additional paths to skill folders - */ paths?: Array - /** - * URLs to fetch skills from (e.g., https://example.com/.well-known/skills/) - */ urls?: Array } watcher?: { ignore?: Array } - /** - * Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true. - */ snapshot?: boolean plugin?: Array< | string @@ -1518,53 +1131,23 @@ export type Config = { }, ] > - /** - * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing - */ share?: "manual" | "auto" | "disabled" - /** - * @deprecated Use 'share' field instead. Share newly created sessions automatically - */ autoshare?: boolean /** * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications */ autoupdate?: boolean | "notify" - /** - * Disable providers that are loaded automatically - */ disabled_providers?: Array - /** - * When set, ONLY these providers will be enabled. All other providers will be ignored - */ enabled_providers?: Array - /** - * Model to use in the format of provider/model, eg anthropic/claude-2 - */ model?: string - /** - * Small model to use for tasks like title generation in the format of provider/model - */ small_model?: string - /** - * Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid. - */ default_agent?: string - /** - * Custom username to display in conversations instead of system username - */ username?: string - /** - * @deprecated Use `agent` field instead. - */ mode?: { build?: AgentConfig plan?: AgentConfig [key: string]: AgentConfig | undefined } - /** - * Agent configuration, see https://opencode.ai/docs/agents - */ agent?: { plan?: AgentConfig build?: AgentConfig @@ -1575,15 +1158,9 @@ export type Config = { compaction?: AgentConfig [key: string]: AgentConfig | undefined } - /** - * Custom provider configurations and model overrides - */ provider?: { [key: string]: ProviderConfig } - /** - * MCP (Model Context Protocol) server configurations - */ mcp?: { [key: string]: | McpLocalConfig @@ -1629,9 +1206,6 @@ export type Config = { } } } - /** - * Additional instruction files or patterns to include - */ instructions?: Array layout?: LayoutConfig permission?: PermissionConfig @@ -1639,121 +1213,29 @@ export type Config = { [key: string]: boolean } enterprise?: { - /** - * Enterprise URL - */ url?: string } - /** - * Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned. - */ tool_output?: { - /** - * Maximum lines of tool output before it is truncated and saved to disk (default: 2000) - */ max_lines?: number - /** - * Maximum bytes of tool output before it is truncated and saved to disk (default: 51200) - */ max_bytes?: number } compaction?: { - /** - * Enable automatic compaction when context is full (default: true) - */ auto?: boolean - /** - * Enable pruning of old tool outputs (default: true) - */ prune?: boolean - /** - * Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2) - */ tail_turns?: number - /** - * Maximum number of tokens from recent turns to preserve verbatim after compaction - */ preserve_recent_tokens?: number - /** - * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. - */ reserved?: number } experimental?: { disable_paste_summary?: boolean - /** - * Enable the batch tool - */ batch_tool?: boolean - /** - * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) - */ openTelemetry?: boolean - /** - * Tools that should only be available to primary agents. - */ primary_tools?: Array - /** - * Continue the agent loop when a tool call is denied - */ continue_loop_on_deny?: boolean - /** - * Timeout in milliseconds for model context protocol (MCP) requests - */ mcp_timeout?: number } } -export type BadRequestError = { - data: unknown - errors: Array<{ - [key: string]: unknown - }> - success: false -} - -export type OAuth = { - type: "oauth" - refresh: string - access: string - expires: number - accountId?: string - enterpriseUrl?: string -} - -export type ApiAuth = { - type: "api" - key: string - metadata?: { - [key: string]: string - } -} - -export type WellKnownAuth = { - type: "wellknown" - key: string - token: string -} - -export type Auth = OAuth | ApiAuth | WellKnownAuth - -export type Workspace = { - id: string - type: string - name: string - branch: string | null - directory: string | null - extra: unknown | null - projectID: string -} - -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } -} - export type Model = { id: string providerID: string @@ -1845,8 +1327,6 @@ export type ConsoleState = { switchableOrgCount: number } -export type ToolIds = Array - export type ToolListItem = { id: string description: string @@ -1855,11 +1335,7 @@ export type ToolListItem = { export type ToolList = Array -export type Worktree = { - name: string - branch: string - directory: string -} +export type ToolIds = Array export type WorktreeCreateInput = { name?: string @@ -1869,6 +1345,12 @@ export type WorktreeCreateInput = { startCommand?: string } +export type Worktree = { + name: string + branch: string + directory: string +} + export type WorktreeRemoveInput = { directory: string } @@ -1901,6 +1383,12 @@ export type GlobalSession = { url: string } title: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } version: string time: { created: number @@ -1926,93 +1414,6 @@ export type McpResource = { client: string } -export type TextPartInput = { - id?: string - type: "text" - text: string - synthetic?: boolean - ignored?: boolean - time?: { - start: number - end?: number - } - metadata?: { - [key: string]: unknown - } -} - -export type FilePartInput = { - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource -} - -export type AgentPartInput = { - id?: string - type: "agent" - name: string - source?: { - value: string - start: number - end: number - } -} - -export type SubtaskPartInput = { - id?: string - type: "subtask" - prompt: string - description: string - agent: string - model?: { - providerID: string - modelID: string - } - command?: string -} - -export type ProviderAuthMethod = { - type: "oauth" | "api" - label: string - prompts?: Array< - | { - type: "text" - key: string - message: string - placeholder?: string - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - | { - type: "select" - key: string - message: string - options: Array<{ - label: string - value: string - hint?: string - }> - when?: { - key: string - op: "eq" | "neq" - value: string - } - } - > -} - -export type ProviderAuthAuthorization = { - url: string - method: "auto" | "code" - instructions: string -} - export type Symbol = { name: string kind: number @@ -2059,64 +1460,82 @@ export type File = { status: "added" | "deleted" | "modified" } -export type Event = - | EventServerInstanceDisposed - | EventFileEdited - | EventFileWatcherUpdated - | EventLspClientDiagnostics - | EventLspUpdated - | EventMessagePartDelta - | EventPermissionAsked - | EventPermissionReplied - | EventSessionDiff - | EventSessionError - | EventInstallationUpdated - | EventInstallationUpdateAvailable - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventTodoUpdated - | EventSessionStatus - | EventSessionIdle - | EventSessionCompacted - | EventTuiPromptAppend - | EventTuiCommandExecute - | EventTuiToastShow - | EventTuiSessionSelect - | EventMcpToolsChanged - | EventMcpBrowserOpenFailed - | EventCommandExecuted - | EventProjectUpdated - | EventVcsBranchUpdated - | EventWorkspaceReady - | EventWorkspaceFailed - | EventWorkspaceRestore - | EventWorkspaceStatus - | EventWorktreeReady - | EventWorktreeFailed - | EventPtyCreated - | EventPtyUpdated - | EventPtyExited - | EventPtyDeleted - | EventMessageUpdated - | EventMessageRemoved - | EventMessagePartUpdated - | EventMessagePartRemoved - | EventSessionCreated - | EventSessionUpdated - | EventSessionDeleted - | EventServerConnected - | EventGlobalDisposed - -export type McpStatusConnected = { - status: "connected" +export type Path = { + home: string + state: string + config: string + worktree: string + directory: string } -export type McpStatusDisabled = { - status: "disabled" +export type VcsInfo = { + branch?: string + default_branch?: string } -export type McpStatusFailed = { +export type VcsFileDiff = { + file: string + patch: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + +export type Command = { + name: string + description?: string + agent?: string + model?: string + source?: "command" | "mcp" | "skill" + template: string + subtask?: boolean + hints: Array +} + +export type Agent = { + name: string + description?: string + mode: "subagent" | "primary" | "all" + native?: boolean + hidden?: boolean + topP?: number + temperature?: number + color?: string + permission: PermissionRuleset + model?: { + modelID: string + providerID: string + } + variant?: string + prompt?: string + options: { + [key: string]: unknown + } + steps?: number +} + +export type LspStatus = { + id: string + name: string + root: string + status: "connected" | "error" +} + +export type FormatterStatus = { + name: string + extensions: Array + enabled: boolean +} + +export type McpStatusConnected = { + status: "connected" +} + +export type McpStatusDisabled = { + status: "disabled" +} + +export type McpStatusFailed = { status: "failed" error: string } @@ -2141,73 +1560,1758 @@ export type McpUnsupportedOAuthError = { error: string } -export type Path = { - home: string - state: string - config: string - worktree: string - directory: string +export type ProviderAuthMethod = { + type: "oauth" | "api" + label: string + prompts?: Array< + | { + type: "text" + key: string + message: string + placeholder?: string + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + | { + type: "select" + key: string + message: string + options: Array<{ + label: string + value: string + hint?: string + }> + when?: { + key: string + op: "eq" | "neq" + value: string + } + } + > +} + +export type ProviderAuthAuthorization = { + url: string + method: "auto" | "code" + instructions: string +} + +export type TextPartInput = { + id?: string + type: "text" + text: string + synthetic?: boolean + ignored?: boolean + time?: { + start: number + end?: number + } + metadata?: { + [key: string]: unknown + } +} + +export type FilePartInput = { + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource +} + +export type AgentPartInput = { + id?: string + type: "agent" + name: string + source?: { + value: string + start: number + end: number + } +} + +export type SubtaskPartInput = { + id?: string + type: "subtask" + prompt: string + description: string + agent: string + model?: { + providerID: string + modelID: string + } + command?: string +} + +export type V2SessionsResponse = { + items: Array + cursor: { + previous?: string + next?: string + } +} + +export type V2SessionMessagesResponse = { + items: Array + cursor: { + previous?: string + next?: string + } +} + +export type EventTuiPromptAppend2 = { + type: "tui.prompt.append" + properties: { + text: string + } +} + +export type EventTuiCommandExecute2 = { + type: "tui.command.execute" + properties: { + command: + | "session.list" + | "session.new" + | "session.share" + | "session.interrupt" + | "session.compact" + | "session.page.up" + | "session.page.down" + | "session.line.up" + | "session.line.down" + | "session.half.page.up" + | "session.half.page.down" + | "session.first" + | "session.last" + | "prompt.clear" + | "prompt.submit" + | "agent.cycle" + | string + } +} + +export type EventTuiToastShow2 = { + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type EventTuiSessionSelect2 = { + type: "tui.session.select" + properties: { + /** + * Session ID to navigate to + */ + sessionID: string + } +} + +export type Workspace = { + id: string + type: string + name: string + branch: string | null + directory: string | null + extra: unknown | null + projectID: string +} + +export type SyncEventMessageUpdated = { + type: "sync" + name: "message.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Message + } +} + +export type SyncEventMessageRemoved = { + type: "sync" + name: "message.removed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + messageID: string + } +} + +export type SyncEventMessagePartUpdated = { + type: "sync" + name: "message.part.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + part: Part + time: number + } +} + +export type SyncEventMessagePartRemoved = { + type: "sync" + name: "message.part.removed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + messageID: string + partID: string + } +} + +export type SyncEventSessionCreated = { + type: "sync" + name: "session.created.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEventSessionUpdated = { + type: "sync" + name: "session.updated.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: { + id?: string | null + slug?: string | null + projectID?: string | null + workspaceID?: string | null + directory?: string | null + path?: string | null + parentID?: string | null + summary?: { + additions: number + deletions: number + files: number + diffs?: Array + } | null + share?: { + url?: string | null + } + title?: string | null + agent?: string | null + model?: { + id: string + providerID: string + variant?: string + } | null + version?: string | null + time?: { + created?: number | null + updated?: number | null + compacting?: number | null + archived?: number | null + } + permission?: PermissionRuleset | null + revert?: { + messageID: string + partID?: string + snapshot?: string + diff?: string + } | null + } + } +} + +export type SyncEventSessionDeleted = { + type: "sync" + name: "session.deleted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + sessionID: string + info: Session + } +} + +export type SyncEventSessionNextAgentSwitched = { + type: "sync" + name: "session.next.agent.switched.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + agent: string + } +} + +export type SyncEventSessionNextModelSwitched = { + type: "sync" + name: "session.next.model.switched.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + id: string + providerID: string + variant?: string + } +} + +export type SyncEventSessionNextPrompted = { + type: "sync" + name: "session.next.prompted.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type SyncEventSessionNextSynthetic = { + type: "sync" + name: "session.next.synthetic.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextShellStarted = { + type: "sync" + name: "session.next.shell.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + command: string + } +} + +export type SyncEventSessionNextShellEnded = { + type: "sync" + name: "session.next.shell.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type SyncEventSessionNextStepStarted = { + type: "sync" + name: "session.next.step.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string + } +} + +export type SyncEventSessionNextStepEnded = { + type: "sync" + name: "session.next.step.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + +export type SyncEventSessionNextTextStarted = { + type: "sync" + name: "session.next.text.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + } +} + +export type SyncEventSessionNextTextDelta = { + type: "sync" + name: "session.next.text.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + delta: string + } +} + +export type SyncEventSessionNextTextEnded = { + type: "sync" + name: "session.next.text.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextReasoningStarted = { + type: "sync" + name: "session.next.reasoning.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type SyncEventSessionNextReasoningDelta = { + type: "sync" + name: "session.next.reasoning.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type SyncEventSessionNextReasoningEnded = { + type: "sync" + name: "session.next.reasoning.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type SyncEventSessionNextToolInputStarted = { + type: "sync" + name: "session.next.tool.input.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type SyncEventSessionNextToolInputDelta = { + type: "sync" + name: "session.next.tool.input.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type SyncEventSessionNextToolInputEnded = { + type: "sync" + name: "session.next.tool.input.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type SyncEventSessionNextToolCalled = { + type: "sync" + name: "session.next.tool.called.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolProgress = { + type: "sync" + name: "session.next.tool.progress.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type SyncEventSessionNextToolSuccess = { + type: "sync" + name: "session.next.tool.success.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextToolError = { + type: "sync" + name: "session.next.tool.error.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + callID: string + error: { + type: string + message: string + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SyncEventSessionNextRetried = { + type: "sync" + name: "session.next.retried.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type SyncEventSessionNextCompactionStarted = { + type: "sync" + name: "session.next.compaction.started.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } +} + +export type SyncEventSessionNextCompactionDelta = { + type: "sync" + name: "session.next.compaction.delta.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + } +} + +export type SyncEventSessionNextCompactionEnded = { + type: "sync" + name: "session.next.compaction.ended.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + text: string + include?: string + } +} + +export type EventServerInstanceDisposed = { + id: string + type: "server.instance.disposed" + properties: { + directory: string + } +} + +export type EventFileEdited = { + id: string + type: "file.edited" + properties: { + file: string + } +} + +export type EventFileWatcherUpdated = { + id: string + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + +export type EventLspClientDiagnostics = { + id: string + type: "lsp.client.diagnostics" + properties: { + serverID: string + path: string + } +} + +export type EventLspUpdated = { + id: string + type: "lsp.updated" + properties: { + [key: string]: unknown + } +} + +export type EventMessagePartDelta = { + id: string + type: "message.part.delta" + properties: { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } +} + +export type EventPermissionAsked = { + id: string + type: "permission.asked" + properties: PermissionRequest +} + +export type EventPermissionReplied = { + id: string + type: "permission.replied" + properties: { + sessionID: string + requestID: string + reply: "once" | "always" | "reject" + } +} + +export type EventSessionDiff = { + id: string + type: "session.diff" + properties: { + sessionID: string + diff: Array + } +} + +export type EventSessionError = { + id: string + type: "session.error" + properties: { + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError + } +} + +export type EventInstallationUpdated = { + id: string + type: "installation.updated" + properties: { + version: string + } +} + +export type EventInstallationUpdateAvailable = { + id: string + type: "installation.update-available" + properties: { + version: string + } +} + +export type EventQuestionAsked = { + id: string + type: "question.asked" + properties: QuestionRequest +} + +export type EventQuestionReplied = { + id: string + type: "question.replied" + properties: QuestionReplied +} + +export type EventQuestionRejected = { + id: string + type: "question.rejected" + properties: QuestionRejected +} + +export type EventTodoUpdated = { + id: string + type: "todo.updated" + properties: { + sessionID: string + todos: Array + } +} + +export type EventSessionStatus = { + id: string + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + id: string + type: "session.idle" + properties: { + sessionID: string + } +} + +export type EventSessionCompacted = { + id: string + type: "session.compacted" + properties: { + sessionID: string + } +} + +export type EventMcpToolsChanged = { + id: string + type: "mcp.tools.changed" + properties: { + server: string + } +} + +export type EventMcpBrowserOpenFailed = { + id: string + type: "mcp.browser.open.failed" + properties: { + mcpName: string + url: string + } +} + +export type EventCommandExecuted = { + id: string + type: "command.executed" + properties: { + name: string + sessionID: string + arguments: string + messageID: string + } +} + +export type EventProjectUpdated = { + id: string + type: "project.updated" + properties: Project +} + +export type EventVcsBranchUpdated = { + id: string + type: "vcs.branch.updated" + properties: { + branch?: string + } +} + +export type EventWorkspaceReady = { + id: string + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + id: string + type: "workspace.failed" + properties: { + message: string + } +} + +export type EventWorkspaceRestore = { + id: string + type: "workspace.restore" + properties: { + workspaceID: string + sessionID: string + total: number + step: number + } +} + +export type EventWorkspaceStatus = { + id: string + type: "workspace.status" + properties: { + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + } +} + +export type EventWorktreeReady = { + id: string + type: "worktree.ready" + properties: { + name: string + branch: string + } +} + +export type EventWorktreeFailed = { + id: string + type: "worktree.failed" + properties: { + message: string + } +} + +export type EventPtyCreated = { + id: string + type: "pty.created" + properties: { + info: Pty + } +} + +export type EventPtyUpdated = { + id: string + type: "pty.updated" + properties: { + info: Pty + } +} + +export type EventPtyExited = { + id: string + type: "pty.exited" + properties: { + id: string + exitCode: number + } +} + +export type EventPtyDeleted = { + id: string + type: "pty.deleted" + properties: { + id: string + } +} + +export type EventMessageUpdated = { + id: string + type: "message.updated" + properties: { + sessionID: string + info: Message + } +} + +export type EventMessageRemoved = { + id: string + type: "message.removed" + properties: { + sessionID: string + messageID: string + } +} + +export type EventMessagePartUpdated = { + id: string + type: "message.part.updated" + properties: { + sessionID: string + part: Part + time: number + } +} + +export type EventMessagePartRemoved = { + id: string + type: "message.part.removed" + properties: { + sessionID: string + messageID: string + partID: string + } +} + +export type EventSessionCreated = { + id: string + type: "session.created" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionUpdated = { + id: string + type: "session.updated" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionDeleted = { + id: string + type: "session.deleted" + properties: { + sessionID: string + info: Session + } +} + +export type EventSessionNextAgentSwitched = { + id: string + type: "session.next.agent.switched" + properties: { + timestamp: number + sessionID: string + agent: string + } +} + +export type EventSessionNextModelSwitched = { + id: string + type: "session.next.model.switched" + properties: { + timestamp: number + sessionID: string + id: string + providerID: string + variant?: string + } +} + +export type PromptSource = { + start: number + end: number + text: string +} + +export type PromptFileAttachment = { + uri: string + mime: string + name?: string + description?: string + source?: PromptSource +} + +export type PromptAgentAttachment = { + name: string + source?: PromptSource +} + +export type EventSessionNextPrompted = { + id: string + type: "session.next.prompted" + properties: { + timestamp: number + sessionID: string + prompt: Prompt + } +} + +export type EventSessionNextSynthetic = { + id: string + type: "session.next.synthetic" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextShellStarted = { + id: string + type: "session.next.shell.started" + properties: { + timestamp: number + sessionID: string + callID: string + command: string + } +} + +export type EventSessionNextShellEnded = { + id: string + type: "session.next.shell.ended" + properties: { + timestamp: number + sessionID: string + callID: string + output: string + } +} + +export type EventSessionNextStepStarted = { + id: string + type: "session.next.step.started" + properties: { + timestamp: number + sessionID: string + agent: string + model: { + id: string + providerID: string + variant?: string + } + snapshot?: string + } +} + +export type EventSessionNextStepEnded = { + id: string + type: "session.next.step.ended" + properties: { + timestamp: number + sessionID: string + finish: string + cost: number + tokens: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + snapshot?: string + } +} + +export type EventSessionNextTextStarted = { + id: string + type: "session.next.text.started" + properties: { + timestamp: number + sessionID: string + } +} + +export type EventSessionNextTextDelta = { + id: string + type: "session.next.text.delta" + properties: { + timestamp: number + sessionID: string + delta: string + } +} + +export type EventSessionNextTextEnded = { + id: string + type: "session.next.text.ended" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextReasoningStarted = { + id: string + type: "session.next.reasoning.started" + properties: { + timestamp: number + sessionID: string + reasoningID: string + } +} + +export type EventSessionNextReasoningDelta = { + id: string + type: "session.next.reasoning.delta" + properties: { + timestamp: number + sessionID: string + reasoningID: string + delta: string + } +} + +export type EventSessionNextReasoningEnded = { + id: string + type: "session.next.reasoning.ended" + properties: { + timestamp: number + sessionID: string + reasoningID: string + text: string + } +} + +export type EventSessionNextToolInputStarted = { + id: string + type: "session.next.tool.input.started" + properties: { + timestamp: number + sessionID: string + callID: string + name: string + } +} + +export type EventSessionNextToolInputDelta = { + id: string + type: "session.next.tool.input.delta" + properties: { + timestamp: number + sessionID: string + callID: string + delta: string + } +} + +export type EventSessionNextToolInputEnded = { + id: string + type: "session.next.tool.input.ended" + properties: { + timestamp: number + sessionID: string + callID: string + text: string + } +} + +export type EventSessionNextToolCalled = { + id: string + type: "session.next.tool.called" + properties: { + timestamp: number + sessionID: string + callID: string + tool: string + input: { + [key: string]: unknown + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type ToolTextContent = { + type: "text" + text: string +} + +export type ToolFileContent = { + type: "file" + uri: string + mime: string + name?: string +} + +export type EventSessionNextToolProgress = { + id: string + type: "session.next.tool.progress" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + } +} + +export type EventSessionNextToolSuccess = { + id: string + type: "session.next.tool.success" + properties: { + timestamp: number + sessionID: string + callID: string + structured: { + [key: string]: unknown + } + content: Array + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type EventSessionNextToolError = { + id: string + type: "session.next.tool.error" + properties: { + timestamp: number + sessionID: string + callID: string + error: { + type: string + message: string + } + provider: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + } +} + +export type SessionNextRetryError = { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string + } +} + +export type EventSessionNextRetried = { + id: string + type: "session.next.retried" + properties: { + timestamp: number + sessionID: string + attempt: number + error: SessionNextRetryError + } +} + +export type EventSessionNextCompactionStarted = { + id: string + type: "session.next.compaction.started" + properties: { + timestamp: number + sessionID: string + reason: "auto" | "manual" + } +} + +export type EventSessionNextCompactionDelta = { + id: string + type: "session.next.compaction.delta" + properties: { + timestamp: number + sessionID: string + text: string + } +} + +export type EventSessionNextCompactionEnded = { + id: string + type: "session.next.compaction.ended" + properties: { + timestamp: number + sessionID: string + text: string + include?: string + } +} + +export type EventServerConnected = { + id: string + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + id: string + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + +export type SessionInfo = { + id: string + parentID?: string + projectID: string + workspaceID?: string + path?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + time: { + created: number + updated: number + archived?: number + } + title: string +} + +export type SessionDelivery = "immediate" | "deferred" + +export type SessionMessageAgentSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "agent-switched" + agent: string +} + +export type SessionMessageModelSwitched = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + type: "model-switched" + model: { + id: string + providerID: string + variant?: string + } +} + +export type SessionMessageUser = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + text: string + files?: Array + agents?: Array + type: "user" +} + +export type SessionMessageSynthetic = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } + sessionID: string + text: string + type: "synthetic" +} + +export type SessionMessageShell = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "shell" + callID: string + command: string + output: string +} + +export type SessionMessageAssistantText = { + type: "text" + text: string +} + +export type SessionMessageAssistantReasoning = { + type: "reasoning" + id: string + text: string +} + +export type SessionMessageToolStatePending = { + status: "pending" + input: string +} + +export type SessionMessageToolStateRunning = { + status: "running" + input: { + [key: string]: unknown + } + structured: { + [key: string]: unknown + } + content: Array +} + +export type SessionMessageToolStateCompleted = { + status: "completed" + input: { + [key: string]: unknown + } + attachments?: Array + content: Array + structured: { + [key: string]: unknown + } +} + +export type SessionMessageToolStateError = { + status: "error" + input: { + [key: string]: unknown + } + content: Array + structured: { + [key: string]: unknown + } + error: { + type: string + message: string + } +} + +export type SessionMessageAssistantTool = { + type: "tool" + id: string + name: string + provider?: { + executed: boolean + metadata?: { + [key: string]: unknown + } + } + state: + | SessionMessageToolStatePending + | SessionMessageToolStateRunning + | SessionMessageToolStateCompleted + | SessionMessageToolStateError + time: { + created: number + ran?: number + completed?: number + pruned?: number + } +} + +export type SessionMessageAssistant = { + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + completed?: number + } + type: "assistant" + agent: string + model: { + id: string + providerID: string + variant?: string + } + content: Array + snapshot?: { + start?: string + end?: string + } + finish?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { + read: number + write: number + } + } + error?: string +} + +export type SessionMessageCompaction = { + type: "compaction" + reason: "auto" | "manual" + summary: string + include?: string + id: string + metadata?: { + [key: string]: unknown + } + time: { + created: number + } +} + +export type SessionMessage = + | SessionMessageAgentSwitched + | SessionMessageModelSwitched + | SessionMessageUser + | SessionMessageSynthetic + | SessionMessageShell + | SessionMessageAssistant + | SessionMessageCompaction + +export type EventTuiToastShow1 = { + id: string + type: "tui.toast.show" + properties: { + title?: string + message: string + variant: "info" | "success" | "warning" | "error" + duration?: number + } +} + +export type BadRequestError = { + data: unknown + errors: Array<{ + [key: string]: unknown + }> + success: false +} + +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + +export type AuthRemoveData = { + body?: never + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" +} + +export type AuthRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] + +export type AuthRemoveResponses = { + /** + * Successfully removed authentication credentials + */ + 200: boolean } -export type VcsInfo = { - branch?: string - default_branch?: string +export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] + +export type AuthSetData = { + body?: Auth + path: { + providerID: string + } + query?: never + url: "/auth/{providerID}" } -export type VcsFileDiff = { - file: string - patch: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" +export type AuthSetErrors = { + /** + * Bad request + */ + 400: BadRequestError } -export type Command = { - name: string - description?: string - agent?: string - model?: string - source?: "command" | "mcp" | "skill" - template: string - subtask?: boolean - hints: Array +export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] + +export type AuthSetResponses = { + /** + * Successfully set authentication credentials + */ + 200: boolean } -export type Agent = { - name: string - description?: string - mode: "subagent" | "primary" | "all" - native?: boolean - hidden?: boolean - topP?: number - temperature?: number - color?: string - permission: PermissionRuleset - model?: { - modelID: string - providerID: string +export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] + +export type AppLogData = { + body?: { + /** + * Service name for the log entry + */ + service: string + /** + * Log level + */ + level: "debug" | "info" | "error" | "warn" + /** + * Log message + */ + message: string + extra?: { + [key: string]: unknown + } } - variant?: string - prompt?: string - options: { - [key: string]: unknown + path?: never + query?: { + directory?: string + workspace?: string } - steps?: number + url: "/log" } -export type LspStatus = { - id: string - name: string - root: string - status: "connected" | "error" +export type AppLogErrors = { + /** + * Bad request + */ + 400: BadRequestError } -export type FormatterStatus = { - name: string - extensions: Array - enabled: boolean +export type AppLogError = AppLogErrors[keyof AppLogErrors] + +export type AppLogResponses = { + /** + * Log entry written successfully + */ + 200: boolean } +export type AppLogResponse = AppLogResponses[keyof AppLogResponses] + export type GlobalHealthData = { body?: never path?: never @@ -2335,1078 +3439,1008 @@ export type GlobalUpgradeResponses = { export type GlobalUpgradeResponse = GlobalUpgradeResponses[keyof GlobalUpgradeResponses] -export type AuthRemoveData = { +export type EventSubscribeData = { body?: never - path: { - providerID: string + path?: never + query?: { + directory?: string + workspace?: string } - query?: never - url: "/auth/{providerID}" + url: "/event" } -export type AuthRemoveErrors = { +export type EventSubscribeResponses = { /** - * Bad request + * Event stream */ - 400: BadRequestError + 200: Event } -export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] -export type AuthRemoveResponses = { +export type ConfigGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/config" +} + +export type ConfigGetResponses = { /** - * Successfully removed authentication credentials + * Get config info */ - 200: boolean + 200: Config } -export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] +export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] -export type AuthSetData = { - body?: Auth - path: { - providerID: string +export type ConfigUpdateData = { + body?: Config + path?: never + query?: { + directory?: string + workspace?: string } - query?: never - url: "/auth/{providerID}" + url: "/config" } -export type AuthSetErrors = { +export type ConfigUpdateErrors = { /** * Bad request */ 400: BadRequestError } -export type AuthSetError = AuthSetErrors[keyof AuthSetErrors] +export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] -export type AuthSetResponses = { +export type ConfigUpdateResponses = { /** - * Successfully set authentication credentials + * Successfully updated config */ - 200: boolean + 200: Config } -export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses] +export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] -export type AppLogData = { - body?: { - /** - * Service name for the log entry - */ - service: string - /** - * Log level - */ - level: "debug" | "info" | "error" | "warn" - /** - * Log message - */ - message: string - /** - * Additional metadata for the log entry - */ - extra?: { - [key: string]: unknown +export type ConfigProvidersData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/config/providers" +} + +export type ConfigProvidersResponses = { + /** + * List of providers + */ + 200: { + providers: Array + default: { + [key: string]: string } } +} + +export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] + +export type ExperimentalConsoleGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console" +} + +export type ExperimentalConsoleGetResponses = { + /** + * Active Console provider metadata + */ + 200: ConsoleState +} + +export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] + +export type ExperimentalConsoleListOrgsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/console/orgs" +} + +export type ExperimentalConsoleListOrgsResponses = { + /** + * Switchable Console orgs + */ + 200: { + orgs: Array<{ + accountID: string + accountEmail: string + accountUrl: string + orgID: string + orgName: string + active: boolean + }> + } +} + +export type ExperimentalConsoleListOrgsResponse = + ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses] + +export type ExperimentalConsoleSwitchOrgData = { + body?: { + accountID: string + orgID: string + } path?: never query?: { directory?: string workspace?: string } - url: "/log" -} - -export type AppLogErrors = { - /** - * Bad request - */ - 400: BadRequestError + url: "/experimental/console/switch" } -export type AppLogError = AppLogErrors[keyof AppLogErrors] - -export type AppLogResponses = { +export type ExperimentalConsoleSwitchOrgResponses = { /** - * Log entry written successfully + * Switch success */ 200: boolean } -export type AppLogResponse = AppLogResponses[keyof AppLogResponses] +export type ExperimentalConsoleSwitchOrgResponse = + ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses] -export type ExperimentalWorkspaceAdapterListData = { +export type ToolListData = { body?: never path?: never - query?: { + query: { directory?: string workspace?: string + provider: string + model: string } - url: "/experimental/workspace/adapter" + url: "/experimental/tool" } -export type ExperimentalWorkspaceAdapterListResponses = { +export type ToolListErrors = { /** - * Workspace adapters + * Bad request */ - 200: Array<{ - type: string - name: string - description: string - }> + 400: BadRequestError } -export type ExperimentalWorkspaceAdapterListResponse = - ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] +export type ToolListError = ToolListErrors[keyof ToolListErrors] -export type ExperimentalWorkspaceListData = { +export type ToolListResponses = { + /** + * Tools + */ + 200: ToolList +} + +export type ToolListResponse = ToolListResponses[keyof ToolListResponses] + +export type ToolIdsData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace" + url: "/experimental/tool/ids" } -export type ExperimentalWorkspaceListResponses = { +export type ToolIdsErrors = { /** - * Workspaces + * Bad request */ - 200: Array + 400: BadRequestError } -export type ExperimentalWorkspaceListResponse = - ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] +export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] -export type ExperimentalWorkspaceCreateData = { - body?: { - id?: string - type: string - branch: string | null - extra: unknown | null - } +export type ToolIdsResponses = { + /** + * Tool IDs + */ + 200: ToolIds +} + +export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] + +export type WorktreeRemoveData = { + body?: WorktreeRemoveInput path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace" + url: "/experimental/worktree" } -export type ExperimentalWorkspaceCreateErrors = { +export type WorktreeRemoveErrors = { /** * Bad request */ 400: BadRequestError } -export type ExperimentalWorkspaceCreateError = - ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] +export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] -export type ExperimentalWorkspaceCreateResponses = { +export type WorktreeRemoveResponses = { /** - * Workspace created + * Worktree removed */ - 200: Workspace + 200: boolean } -export type ExperimentalWorkspaceCreateResponse = - ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] +export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] -export type ExperimentalWorkspaceStatusData = { +export type WorktreeListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/status" + url: "/experimental/worktree" } -export type ExperimentalWorkspaceStatusResponses = { +export type WorktreeListResponses = { /** - * Workspace status + * List of worktree directories */ - 200: Array<{ - workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" - }> + 200: Array } -export type ExperimentalWorkspaceStatusResponse = - ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] +export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] -export type ExperimentalWorkspaceRemoveData = { - body?: never - path: { - id: string - } +export type WorktreeCreateData = { + body?: WorktreeCreateInput + path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/{id}" + url: "/experimental/worktree" } -export type ExperimentalWorkspaceRemoveErrors = { +export type WorktreeCreateErrors = { /** * Bad request */ 400: BadRequestError } -export type ExperimentalWorkspaceRemoveError = - ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] +export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] -export type ExperimentalWorkspaceRemoveResponses = { +export type WorktreeCreateResponses = { /** - * Workspace removed + * Worktree created */ - 200: Workspace + 200: Worktree } -export type ExperimentalWorkspaceRemoveResponse = - ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] +export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] -export type ExperimentalWorkspaceSessionRestoreData = { - body?: { - sessionID: string - } - path: { - id: string - } +export type WorktreeResetData = { + body?: WorktreeResetInput + path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/{id}/session-restore" + url: "/experimental/worktree/reset" } -export type ExperimentalWorkspaceSessionRestoreErrors = { +export type WorktreeResetErrors = { /** * Bad request */ 400: BadRequestError } -export type ExperimentalWorkspaceSessionRestoreError = - ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] +export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] -export type ExperimentalWorkspaceSessionRestoreResponses = { +export type WorktreeResetResponses = { /** - * Session replay started + * Worktree reset */ - 200: { - total: number - } + 200: boolean } -export type ExperimentalWorkspaceSessionRestoreResponse = - ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] +export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] -export type ProjectListData = { +export type ExperimentalSessionListData = { body?: never path?: never query?: { directory?: string workspace?: string + roots?: boolean | "true" | "false" + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean | "true" | "false" } - url: "/project" + url: "/experimental/session" } -export type ProjectListResponses = { +export type ExperimentalSessionListResponses = { /** - * List of projects + * List of sessions */ - 200: Array + 200: Array } -export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses] +export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] -export type ProjectCurrentData = { +export type ExperimentalResourceListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/project/current" + url: "/experimental/resource" } -export type ProjectCurrentResponses = { +export type ExperimentalResourceListResponses = { /** - * Current project information + * MCP resources */ - 200: Project + 200: { + [key: string]: McpResource + } } -export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] +export type ExperimentalResourceListResponse = + ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] -export type ProjectInitGitData = { +export type FindTextData = { body?: never path?: never - query?: { + query: { directory?: string workspace?: string + pattern: string } - url: "/project/git/init" + url: "/find" } -export type ProjectInitGitResponses = { +export type FindTextResponses = { /** - * Project information after git initialization + * Matches */ - 200: Project + 200: Array<{ + path: { + text: string + } + lines: { + text: string + } + line_number: number + absolute_offset: number + submatches: Array<{ + match: { + text: string + } + start: number + end: number + }> + }> } -export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses] +export type FindTextResponse = FindTextResponses[keyof FindTextResponses] -export type ProjectUpdateData = { - body?: { - name?: string - icon?: { - url?: string - override?: string - color?: string - } - commands?: { - /** - * Startup script to run when creating a new workspace (worktree) - */ - start?: string - } - } - path: { - projectID: string - } - query?: { +export type FindFilesData = { + body?: never + path?: never + query: { directory?: string workspace?: string + query: string + dirs?: "true" | "false" + type?: "file" | "directory" + limit?: number } - url: "/project/{projectID}" -} - -export type ProjectUpdateErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError + url: "/find/file" } -export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors] - -export type ProjectUpdateResponses = { +export type FindFilesResponses = { /** - * Updated project information + * File paths */ - 200: Project + 200: Array } -export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses] +export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] -export type PtyShellsData = { +export type FindSymbolsData = { body?: never path?: never - query?: { + query: { directory?: string workspace?: string + query: string } - url: "/pty/shells" + url: "/find/symbol" } -export type PtyShellsResponses = { +export type FindSymbolsResponses = { /** - * List of shells + * Symbols */ - 200: Array<{ - path: string - name: string - acceptable: boolean - }> + 200: Array } -export type PtyShellsResponse = PtyShellsResponses[keyof PtyShellsResponses] +export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] -export type PtyListData = { +export type FileListData = { body?: never path?: never - query?: { + query: { directory?: string workspace?: string + path: string } - url: "/pty" + url: "/file" } -export type PtyListResponses = { +export type FileListResponses = { /** - * List of sessions + * Files and directories */ - 200: Array + 200: Array } -export type PtyListResponse = PtyListResponses[keyof PtyListResponses] +export type FileListResponse = FileListResponses[keyof FileListResponses] -export type PtyCreateData = { - body?: { - command?: string - args?: Array - cwd?: string - title?: string - env?: { - [key: string]: string - } - } +export type FileReadData = { + body?: never path?: never - query?: { + query: { directory?: string workspace?: string + path: string } - url: "/pty" -} - -export type PtyCreateErrors = { - /** - * Bad request - */ - 400: BadRequestError + url: "/file/content" } -export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors] - -export type PtyCreateResponses = { +export type FileReadResponses = { /** - * Created session + * File content */ - 200: Pty + 200: FileContent } -export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses] +export type FileReadResponse = FileReadResponses[keyof FileReadResponses] -export type PtyRemoveData = { +export type FileStatusData = { body?: never - path: { - ptyID: string - } + path?: never query?: { directory?: string workspace?: string } - url: "/pty/{ptyID}" -} - -export type PtyRemoveErrors = { - /** - * Not found - */ - 404: NotFoundError + url: "/file/status" } -export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors] - -export type PtyRemoveResponses = { +export type FileStatusResponses = { /** - * Session removed + * File status */ - 200: boolean + 200: Array } -export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses] +export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] -export type PtyGetData = { +export type InstanceDisposeData = { body?: never - path: { - ptyID: string - } + path?: never query?: { directory?: string workspace?: string } - url: "/pty/{ptyID}" -} - -export type PtyGetErrors = { - /** - * Not found - */ - 404: NotFoundError + url: "/instance/dispose" } -export type PtyGetError = PtyGetErrors[keyof PtyGetErrors] - -export type PtyGetResponses = { +export type InstanceDisposeResponses = { /** - * Session info + * Instance disposed */ - 200: Pty + 200: boolean } -export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses] +export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] -export type PtyUpdateData = { - body?: { - title?: string - size?: { - rows: number - cols: number - } - } - path: { - ptyID: string - } +export type PathGetData = { + body?: never + path?: never query?: { directory?: string workspace?: string } - url: "/pty/{ptyID}" -} - -export type PtyUpdateErrors = { - /** - * Bad request - */ - 400: BadRequestError + url: "/path" } -export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors] - -export type PtyUpdateResponses = { +export type PathGetResponses = { /** - * Updated session + * Path */ - 200: Pty + 200: Path } -export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] +export type PathGetResponse = PathGetResponses[keyof PathGetResponses] -export type PtyConnectData = { +export type VcsGetData = { body?: never - path: { - ptyID: string - } + path?: never query?: { directory?: string workspace?: string } - url: "/pty/{ptyID}/connect" -} - -export type PtyConnectErrors = { - /** - * Not found - */ - 404: NotFoundError + url: "/vcs" } -export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] - -export type PtyConnectResponses = { +export type VcsGetResponses = { /** - * Connected session + * VCS info */ - 200: boolean + 200: VcsInfo } -export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] -export type ConfigGetData = { +export type VcsDiffData = { body?: never path?: never - query?: { + query: { directory?: string workspace?: string + mode: "git" | "branch" } - url: "/config" + url: "/vcs/diff" } -export type ConfigGetResponses = { +export type VcsDiffResponses = { /** - * Get config info + * VCS diff */ - 200: Config + 200: Array } -export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses] +export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] -export type ConfigUpdateData = { - body?: Config +export type CommandListData = { + body?: never path?: never query?: { directory?: string workspace?: string } - url: "/config" -} - -export type ConfigUpdateErrors = { - /** - * Bad request - */ - 400: BadRequestError + url: "/command" } -export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors] - -export type ConfigUpdateResponses = { +export type CommandListResponses = { /** - * Successfully updated config + * List of commands */ - 200: Config + 200: Array } -export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type CommandListResponse = CommandListResponses[keyof CommandListResponses] -export type ConfigProvidersData = { +export type AppAgentsData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/config/providers" + url: "/agent" } -export type ConfigProvidersResponses = { - /** - * List of providers - */ - 200: { - providers: Array - default: { - [key: string]: string - } - } +export type AppAgentsResponses = { + /** + * List of agents + */ + 200: Array } -export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] -export type ExperimentalConsoleGetData = { +export type AppSkillsData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/console" + url: "/skill" } -export type ExperimentalConsoleGetResponses = { +export type AppSkillsResponses = { /** - * Active Console provider metadata + * List of skills */ - 200: ConsoleState + 200: Array<{ + name: string + description: string + location: string + content: string + }> } -export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] +export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] -export type ExperimentalConsoleListOrgsData = { +export type LspStatusData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/console/orgs" + url: "/lsp" } -export type ExperimentalConsoleListOrgsResponses = { +export type LspStatusResponses = { /** - * Switchable Console orgs + * LSP server status */ - 200: { - orgs: Array<{ - accountID: string - accountEmail: string - accountUrl: string - orgID: string - orgName: string - active: boolean - }> - } + 200: Array } -export type ExperimentalConsoleListOrgsResponse = - ExperimentalConsoleListOrgsResponses[keyof ExperimentalConsoleListOrgsResponses] +export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] -export type ExperimentalConsoleSwitchOrgData = { - body?: { - accountID: string - orgID: string - } +export type FormatterStatusData = { + body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/console/switch" + url: "/formatter" } -export type ExperimentalConsoleSwitchOrgResponses = { +export type FormatterStatusResponses = { /** - * Switch success + * Formatter status */ - 200: boolean + 200: Array } -export type ExperimentalConsoleSwitchOrgResponse = - ExperimentalConsoleSwitchOrgResponses[keyof ExperimentalConsoleSwitchOrgResponses] +export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] -export type ToolIdsData = { +export type McpStatusData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/experimental/tool/ids" -} - -export type ToolIdsErrors = { - /** - * Bad request - */ - 400: BadRequestError + url: "/mcp" } -export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors] - -export type ToolIdsResponses = { +export type McpStatusResponses = { /** - * Tool IDs + * MCP server status */ - 200: ToolIds + 200: { + [key: string]: McpStatus + } } -export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses] +export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] -export type ToolListData = { - body?: never +export type McpAddData = { + body?: { + name: string + config: McpLocalConfig | McpRemoteConfig + } path?: never - query: { + query?: { directory?: string workspace?: string - provider: string - model: string } - url: "/experimental/tool" + url: "/mcp" } -export type ToolListErrors = { +export type McpAddErrors = { /** * Bad request */ 400: BadRequestError } -export type ToolListError = ToolListErrors[keyof ToolListErrors] +export type McpAddError = McpAddErrors[keyof McpAddErrors] -export type ToolListResponses = { +export type McpAddResponses = { /** - * Tools + * MCP server added successfully */ - 200: ToolList + 200: { + [key: string]: McpStatus + } } -export type ToolListResponse = ToolListResponses[keyof ToolListResponses] +export type McpAddResponse = McpAddResponses[keyof McpAddResponses] -export type WorktreeRemoveData = { - body?: WorktreeRemoveInput - path?: never +export type McpAuthRemoveData = { + body?: never + path: { + name: string + } query?: { directory?: string workspace?: string } - url: "/experimental/worktree" + url: "/mcp/{name}/auth" } -export type WorktreeRemoveErrors = { +export type McpAuthRemoveErrors = { /** - * Bad request + * Not found */ - 400: BadRequestError + 404: NotFoundError } -export type WorktreeRemoveError = WorktreeRemoveErrors[keyof WorktreeRemoveErrors] +export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] -export type WorktreeRemoveResponses = { +export type McpAuthRemoveResponses = { /** - * Worktree removed + * OAuth credentials removed */ - 200: boolean + 200: { + success: true + } } -export type WorktreeRemoveResponse = WorktreeRemoveResponses[keyof WorktreeRemoveResponses] +export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] -export type WorktreeListData = { +export type McpAuthStartData = { body?: never - path?: never + path: { + name: string + } query?: { directory?: string workspace?: string } - url: "/experimental/worktree" + url: "/mcp/{name}/auth" } -export type WorktreeListResponses = { +export type McpAuthStartErrors = { /** - * List of worktree directories + * McpUnsupportedOAuthError */ - 200: Array + 400: McpUnsupportedOAuthError + /** + * Not found + */ + 404: NotFoundError } -export type WorktreeListResponse = WorktreeListResponses[keyof WorktreeListResponses] +export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] -export type WorktreeCreateData = { - body?: WorktreeCreateInput - path?: never +export type McpAuthStartResponses = { + /** + * OAuth flow started + */ + 200: { + authorizationUrl: string + oauthState: string + } +} + +export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] + +export type McpAuthCallbackData = { + body?: { + code: string + } + path: { + name: string + } query?: { directory?: string workspace?: string } - url: "/experimental/worktree" + url: "/mcp/{name}/auth/callback" } -export type WorktreeCreateErrors = { +export type McpAuthCallbackErrors = { /** * Bad request */ 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError } -export type WorktreeCreateError = WorktreeCreateErrors[keyof WorktreeCreateErrors] +export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] -export type WorktreeCreateResponses = { +export type McpAuthCallbackResponses = { /** - * Worktree created + * OAuth authentication completed */ - 200: Worktree + 200: McpStatus } -export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] +export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] -export type WorktreeResetData = { - body?: WorktreeResetInput - path?: never +export type McpAuthAuthenticateData = { + body?: never + path: { + name: string + } query?: { directory?: string workspace?: string } - url: "/experimental/worktree/reset" + url: "/mcp/{name}/auth/authenticate" } -export type WorktreeResetErrors = { +export type McpAuthAuthenticateErrors = { /** - * Bad request + * McpUnsupportedOAuthError */ - 400: BadRequestError + 400: McpUnsupportedOAuthError + /** + * Not found + */ + 404: NotFoundError } -export type WorktreeResetError = WorktreeResetErrors[keyof WorktreeResetErrors] +export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] -export type WorktreeResetResponses = { +export type McpAuthAuthenticateResponses = { /** - * Worktree reset + * OAuth authentication completed */ - 200: boolean + 200: McpStatus } -export type WorktreeResetResponse = WorktreeResetResponses[keyof WorktreeResetResponses] +export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] -export type ExperimentalSessionListData = { +export type McpConnectData = { body?: never - path?: never + path: { + name: string + } query?: { - /** - * Filter sessions by project directory - */ - directory?: string - workspace?: string - /** - * Only return root sessions (no parentID) - */ - roots?: boolean | "true" | "false" - /** - * Filter sessions updated on or after this timestamp (milliseconds since epoch) - */ - start?: number - /** - * Return sessions updated before this timestamp (milliseconds since epoch) - */ - cursor?: number - /** - * Filter sessions by title (case-insensitive) - */ - search?: string - /** - * Maximum number of sessions to return - */ - limit?: number - /** - * Include archived sessions (default false) - */ - archived?: boolean | "true" | "false" + directory?: string + workspace?: string } - url: "/experimental/session" + url: "/mcp/{name}/connect" } -export type ExperimentalSessionListResponses = { +export type McpConnectResponses = { /** - * List of sessions + * MCP server connected successfully */ - 200: Array + 200: boolean } -export type ExperimentalSessionListResponse = ExperimentalSessionListResponses[keyof ExperimentalSessionListResponses] +export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] -export type ExperimentalResourceListData = { +export type McpDisconnectData = { body?: never - path?: never + path: { + name: string + } query?: { directory?: string workspace?: string } - url: "/experimental/resource" + url: "/mcp/{name}/disconnect" } -export type ExperimentalResourceListResponses = { +export type McpDisconnectResponses = { /** - * MCP resources + * MCP server disconnected successfully */ - 200: { - [key: string]: McpResource - } + 200: boolean } -export type ExperimentalResourceListResponse = - ExperimentalResourceListResponses[keyof ExperimentalResourceListResponses] +export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] -export type SessionListData = { +export type ProjectListData = { body?: never path?: never query?: { - /** - * Filter sessions by directory - */ directory?: string workspace?: string - /** - * List all sessions for the current project - */ - scope?: "project" - /** - * Filter sessions by project-relative path - */ - path?: string - /** - * Only return root sessions (no parentID) - */ - roots?: boolean | "true" | "false" - /** - * Filter sessions updated on or after this timestamp (milliseconds since epoch) - */ - start?: number - /** - * Filter sessions by title (case-insensitive) - */ - search?: string - /** - * Maximum number of sessions to return - */ - limit?: number } - url: "/session" + url: "/project" } -export type SessionListResponses = { +export type ProjectListResponses = { /** - * List of sessions + * List of projects */ - 200: Array + 200: Array } -export type SessionListResponse = SessionListResponses[keyof SessionListResponses] +export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses] -export type SessionCreateData = { - body?: { - parentID?: string - title?: string - permission?: PermissionRuleset - workspaceID?: string - } +export type ProjectCurrentData = { + body?: never path?: never query?: { directory?: string workspace?: string } - url: "/session" -} - -export type SessionCreateErrors = { - /** - * Bad request - */ - 400: BadRequestError + url: "/project/current" } -export type SessionCreateError = SessionCreateErrors[keyof SessionCreateErrors] - -export type SessionCreateResponses = { +export type ProjectCurrentResponses = { /** - * Successfully created session + * Current project information */ - 200: Session + 200: Project } -export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses] +export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] -export type SessionStatusData = { +export type ProjectInitGitData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/session/status" + url: "/project/git/init" } -export type SessionStatusErrors = { +export type ProjectInitGitResponses = { /** - * Bad request + * Project information after git initialization */ - 400: BadRequestError + 200: Project } -export type SessionStatusError = SessionStatusErrors[keyof SessionStatusErrors] +export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses] -export type SessionStatusResponses = { - /** - * Get session status - */ - 200: { - [key: string]: SessionStatus +export type ProjectUpdateData = { + body?: { + name?: string + icon?: { + url?: string + override?: string + color?: string + } + commands?: { + /** + * Startup script to run when creating a new workspace (worktree) + */ + start?: string + } } -} - -export type SessionStatusResponse = SessionStatusResponses[keyof SessionStatusResponses] - -export type SessionDeleteData = { - body?: never path: { - sessionID: string + projectID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}" + url: "/project/{projectID}" } -export type SessionDeleteErrors = { +export type ProjectUpdateErrors = { /** * Bad request */ @@ -3417,233 +4451,228 @@ export type SessionDeleteErrors = { 404: NotFoundError } -export type SessionDeleteError = SessionDeleteErrors[keyof SessionDeleteErrors] +export type ProjectUpdateError = ProjectUpdateErrors[keyof ProjectUpdateErrors] -export type SessionDeleteResponses = { +export type ProjectUpdateResponses = { /** - * Successfully deleted session + * Updated project information */ - 200: boolean + 200: Project } -export type SessionDeleteResponse = SessionDeleteResponses[keyof SessionDeleteResponses] +export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses] -export type SessionGetData = { +export type PtyShellsData = { body?: never - path: { - sessionID: string - } + path?: never query?: { directory?: string workspace?: string } - url: "/session/{sessionID}" + url: "/pty/shells" } -export type SessionGetErrors = { - /** - * Bad request - */ - 400: BadRequestError +export type PtyShellsResponses = { /** - * Not found + * List of shells */ - 404: NotFoundError + 200: Array<{ + path: string + name: string + acceptable: boolean + }> } -export type SessionGetError = SessionGetErrors[keyof SessionGetErrors] +export type PtyShellsResponse = PtyShellsResponses[keyof PtyShellsResponses] -export type SessionGetResponses = { +export type PtyListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/pty" +} + +export type PtyListResponses = { /** - * Get session + * List of sessions */ - 200: Session + 200: Array } -export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] +export type PtyListResponse = PtyListResponses[keyof PtyListResponses] -export type SessionUpdateData = { +export type PtyCreateData = { body?: { + command?: string + args?: Array + cwd?: string title?: string - permission?: PermissionRuleset - time?: { - archived?: number + env?: { + [key: string]: string } } - path: { - sessionID: string - } + path?: never query?: { directory?: string workspace?: string } - url: "/session/{sessionID}" + url: "/pty" } -export type SessionUpdateErrors = { +export type PtyCreateErrors = { /** * Bad request */ 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError } -export type SessionUpdateError = SessionUpdateErrors[keyof SessionUpdateErrors] +export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors] -export type SessionUpdateResponses = { +export type PtyCreateResponses = { /** - * Successfully updated session + * Created session */ - 200: Session + 200: Pty } -export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses] +export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses] -export type SessionChildrenData = { +export type PtyRemoveData = { body?: never path: { - sessionID: string + ptyID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/children" + url: "/pty/{ptyID}" } -export type SessionChildrenErrors = { - /** - * Bad request - */ - 400: BadRequestError +export type PtyRemoveErrors = { /** * Not found */ 404: NotFoundError } -export type SessionChildrenError = SessionChildrenErrors[keyof SessionChildrenErrors] +export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors] -export type SessionChildrenResponses = { +export type PtyRemoveResponses = { /** - * List of children + * Session removed */ - 200: Array + 200: boolean } -export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses] +export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses] -export type SessionTodoData = { +export type PtyGetData = { body?: never path: { - sessionID: string + ptyID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/todo" + url: "/pty/{ptyID}" } -export type SessionTodoErrors = { - /** - * Bad request - */ - 400: BadRequestError +export type PtyGetErrors = { /** * Not found */ 404: NotFoundError } -export type SessionTodoError = SessionTodoErrors[keyof SessionTodoErrors] +export type PtyGetError = PtyGetErrors[keyof PtyGetErrors] -export type SessionTodoResponses = { +export type PtyGetResponses = { /** - * Todo list + * Session info */ - 200: Array + 200: Pty } -export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] +export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses] -export type SessionInitData = { +export type PtyUpdateData = { body?: { - modelID: string - providerID: string - messageID: string + title?: string + size?: { + rows: number + cols: number + } } path: { - sessionID: string + ptyID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/init" + url: "/pty/{ptyID}" } -export type SessionInitErrors = { +export type PtyUpdateErrors = { /** * Bad request */ 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError } -export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] +export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors] -export type SessionInitResponses = { +export type PtyUpdateResponses = { /** - * 200 + * Updated session */ - 200: boolean + 200: Pty } -export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] +export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] -export type SessionForkData = { - body?: { - messageID?: string - } - path: { - sessionID: string - } +export type QuestionListData = { + body?: never + path?: never query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/fork" + url: "/question" } -export type SessionForkResponses = { +export type QuestionListResponses = { /** - * 200 + * List of pending questions */ - 200: Session + 200: Array } -export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] +export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] -export type SessionAbortData = { - body?: never +export type QuestionReplyData = { + body?: { + /** + * User answers in order of questions (each answer is an array of selected labels) + */ + answers: Array + } path: { - sessionID: string + requestID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/abort" + url: "/question/{requestID}/reply" } -export type SessionAbortErrors = { +export type QuestionReplyErrors = { /** * Bad request */ @@ -3654,30 +4683,30 @@ export type SessionAbortErrors = { 404: NotFoundError } -export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] +export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] -export type SessionAbortResponses = { +export type QuestionReplyResponses = { /** - * Aborted session + * Question answered successfully */ 200: boolean } -export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] +export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] -export type SessionUnshareData = { +export type QuestionRejectData = { body?: never path: { - sessionID: string + requestID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/share" + url: "/question/{requestID}/reject" } -export type SessionUnshareErrors = { +export type QuestionRejectErrors = { /** * Bad request */ @@ -3688,30 +4717,52 @@ export type SessionUnshareErrors = { 404: NotFoundError } -export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] +export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] -export type SessionUnshareResponses = { +export type QuestionRejectResponses = { /** - * Successfully unshared session + * Question rejected successfully */ - 200: Session + 200: boolean } -export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] +export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] -export type SessionShareData = { +export type PermissionListData = { body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/permission" +} + +export type PermissionListResponses = { + /** + * List of pending permissions + */ + 200: Array +} + +export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] + +export type PermissionReplyData = { + body?: { + reply: "once" | "always" | "reject" + message?: string + } path: { - sessionID: string + requestID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/share" + url: "/permission/{requestID}/reply" } -export type SessionShareErrors = { +export type PermissionReplyErrors = { /** * Bad request */ @@ -3722,262 +4773,244 @@ export type SessionShareErrors = { 404: NotFoundError } -export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] +export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] -export type SessionShareResponses = { +export type PermissionReplyResponses = { /** - * Successfully shared session + * Permission processed successfully */ - 200: Session + 200: boolean } -export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] +export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] -export type SessionDiffData = { +export type ProviderListData = { body?: never - path: { - sessionID: string - } + path?: never query?: { directory?: string workspace?: string - messageID?: string } - url: "/session/{sessionID}/diff" + url: "/provider" } -export type SessionDiffResponses = { +export type ProviderListResponses = { /** - * Successfully retrieved diff + * List of providers */ - 200: Array + 200: { + all: Array + default: { + [key: string]: string + } + connected: Array + } } -export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] +export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] -export type SessionSummarizeData = { - body?: { - providerID: string - modelID: string - auto?: boolean - } - path: { - sessionID: string - } +export type ProviderAuthData = { + body?: never + path?: never query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/summarize" -} - -export type SessionSummarizeErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError + url: "/provider/auth" } -export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] - -export type SessionSummarizeResponses = { +export type ProviderAuthResponses = { /** - * Summarized session + * Provider auth methods */ - 200: boolean + 200: { + [key: string]: Array + } } -export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] +export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] -export type SessionMessagesData = { - body?: never +export type ProviderOauthAuthorizeData = { + body?: { + /** + * Auth method index + */ + method: number + inputs?: { + [key: string]: string + } + } path: { - sessionID: string + providerID: string } query?: { directory?: string workspace?: string - /** - * Maximum number of messages to return - */ - limit?: number - before?: string } - url: "/session/{sessionID}/message" + url: "/provider/{providerID}/oauth/authorize" } -export type SessionMessagesErrors = { +export type ProviderOauthAuthorizeErrors = { /** * Bad request */ 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError } -export type SessionMessagesError = SessionMessagesErrors[keyof SessionMessagesErrors] +export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] -export type SessionMessagesResponses = { +export type ProviderOauthAuthorizeResponses = { /** - * List of messages + * Authorization URL and method */ - 200: Array<{ - info: Message - parts: Array - }> + 200: ProviderAuthAuthorization } -export type SessionMessagesResponse = SessionMessagesResponses[keyof SessionMessagesResponses] +export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] -export type SessionPromptData = { +export type ProviderOauthCallbackData = { body?: { - messageID?: string - model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean /** - * @deprecated tools and permissions have been merged, you can set permissions on the session itself now + * Auth method index */ - tools?: { - [key: string]: boolean - } - format?: OutputFormat - system?: string - variant?: string - parts: Array + method: number + code?: string } path: { - sessionID: string + providerID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message" + url: "/provider/{providerID}/oauth/callback" } -export type SessionPromptErrors = { +export type ProviderOauthCallbackErrors = { /** * Bad request */ 400: BadRequestError +} + +export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] + +export type ProviderOauthCallbackResponses = { /** - * Not found + * OAuth callback processed successfully */ - 404: NotFoundError + 200: boolean } -export type SessionPromptError = SessionPromptErrors[keyof SessionPromptErrors] +export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] -export type SessionPromptResponses = { +export type SessionListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + scope?: "project" + path?: string + roots?: boolean | "true" | "false" + start?: number + search?: string + limit?: number + } + url: "/session" +} + +export type SessionListResponses = { /** - * Created message + * List of sessions */ - 200: { - info: AssistantMessage - parts: Array - } + 200: Array } -export type SessionPromptResponse = SessionPromptResponses[keyof SessionPromptResponses] +export type SessionListResponse = SessionListResponses[keyof SessionListResponses] -export type SessionDeleteMessageData = { - body?: never - path: { - sessionID: string - messageID: string +export type SessionCreateData = { + body?: { + parentID?: string + title?: string + agent?: string + model?: { + id: string + providerID: string + variant?: string + } + permission?: PermissionRuleset + workspaceID?: string } + path?: never query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}" + url: "/session" } -export type SessionDeleteMessageErrors = { +export type SessionCreateErrors = { /** * Bad request */ 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError } -export type SessionDeleteMessageError = SessionDeleteMessageErrors[keyof SessionDeleteMessageErrors] +export type SessionCreateError = SessionCreateErrors[keyof SessionCreateErrors] -export type SessionDeleteMessageResponses = { +export type SessionCreateResponses = { /** - * Successfully deleted message + * Successfully created session */ - 200: boolean + 200: Session } -export type SessionDeleteMessageResponse = SessionDeleteMessageResponses[keyof SessionDeleteMessageResponses] +export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses] -export type SessionMessageData = { +export type SessionStatusData = { body?: never - path: { - sessionID: string - messageID: string - } + path?: never query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}" + url: "/session/status" } -export type SessionMessageErrors = { +export type SessionStatusErrors = { /** * Bad request */ 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError } -export type SessionMessageError = SessionMessageErrors[keyof SessionMessageErrors] +export type SessionStatusError = SessionStatusErrors[keyof SessionStatusErrors] -export type SessionMessageResponses = { +export type SessionStatusResponses = { /** - * Message + * Get session status */ 200: { - info: Message - parts: Array + [key: string]: SessionStatus } } -export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] +export type SessionStatusResponse = SessionStatusResponses[keyof SessionStatusResponses] -export type PartDeleteData = { +export type SessionDeleteData = { body?: never path: { sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}" } -export type PartDeleteErrors = { +export type SessionDeleteErrors = { /** * Bad request */ @@ -3988,32 +5021,30 @@ export type PartDeleteErrors = { 404: NotFoundError } -export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] +export type SessionDeleteError = SessionDeleteErrors[keyof SessionDeleteErrors] -export type PartDeleteResponses = { +export type SessionDeleteResponses = { /** - * Successfully deleted part + * Successfully deleted session */ 200: boolean } -export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] +export type SessionDeleteResponse = SessionDeleteResponses[keyof SessionDeleteResponses] -export type PartUpdateData = { - body?: Part +export type SessionGetData = { + body?: never path: { sessionID: string - messageID: string - partID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/message/{messageID}/part/{partID}" + url: "/session/{sessionID}" } -export type PartUpdateErrors = { +export type SessionGetErrors = { /** * Bad request */ @@ -4024,36 +5055,24 @@ export type PartUpdateErrors = { 404: NotFoundError } -export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] +export type SessionGetError = SessionGetErrors[keyof SessionGetErrors] -export type PartUpdateResponses = { +export type SessionGetResponses = { /** - * Successfully updated part + * Get session */ - 200: Part + 200: Session } -export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] +export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses] -export type SessionPromptAsyncData = { +export type SessionUpdateData = { body?: { - messageID?: string - model?: { - providerID: string - modelID: string - } - agent?: string - noReply?: boolean - /** - * @deprecated tools and permissions have been merged, you can set permissions on the session itself now - */ - tools?: { - [key: string]: boolean + title?: string + permission?: PermissionRuleset + time?: { + archived?: number } - format?: OutputFormat - system?: string - variant?: string - parts: Array } path: { sessionID: string @@ -4062,10 +5081,10 @@ export type SessionPromptAsyncData = { directory?: string workspace?: string } - url: "/session/{sessionID}/prompt_async" + url: "/session/{sessionID}" } -export type SessionPromptAsyncErrors = { +export type SessionUpdateErrors = { /** * Bad request */ @@ -4076,34 +5095,19 @@ export type SessionPromptAsyncErrors = { 404: NotFoundError } -export type SessionPromptAsyncError = SessionPromptAsyncErrors[keyof SessionPromptAsyncErrors] +export type SessionUpdateError = SessionUpdateErrors[keyof SessionUpdateErrors] -export type SessionPromptAsyncResponses = { +export type SessionUpdateResponses = { /** - * Prompt accepted + * Successfully updated session */ - 204: void + 200: Session } -export type SessionPromptAsyncResponse = SessionPromptAsyncResponses[keyof SessionPromptAsyncResponses] +export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses] -export type SessionCommandData = { - body?: { - messageID?: string - agent?: string - model?: string - arguments: string - command: string - variant?: string - parts?: Array<{ - id?: string - type: "file" - mime: string - filename?: string - url: string - source?: FilePartSource - }> - } +export type SessionChildrenData = { + body?: never path: { sessionID: string } @@ -4111,10 +5115,10 @@ export type SessionCommandData = { directory?: string workspace?: string } - url: "/session/{sessionID}/command" + url: "/session/{sessionID}/children" } -export type SessionCommandErrors = { +export type SessionChildrenErrors = { /** * Bad request */ @@ -4125,30 +5129,19 @@ export type SessionCommandErrors = { 404: NotFoundError } -export type SessionCommandError = SessionCommandErrors[keyof SessionCommandErrors] +export type SessionChildrenError = SessionChildrenErrors[keyof SessionChildrenErrors] -export type SessionCommandResponses = { +export type SessionChildrenResponses = { /** - * Created message + * List of children */ - 200: { - info: AssistantMessage - parts: Array - } + 200: Array } -export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses] +export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses] -export type SessionShellData = { - body?: { - messageID?: string - agent: string - model?: { - providerID: string - modelID: string - } - command: string - } +export type SessionTodoData = { + body?: never path: { sessionID: string } @@ -4156,10 +5149,10 @@ export type SessionShellData = { directory?: string workspace?: string } - url: "/session/{sessionID}/shell" + url: "/session/{sessionID}/todo" } -export type SessionShellErrors = { +export type SessionTodoErrors = { /** * Bad request */ @@ -4170,36 +5163,54 @@ export type SessionShellErrors = { 404: NotFoundError } -export type SessionShellError = SessionShellErrors[keyof SessionShellErrors] +export type SessionTodoError = SessionTodoErrors[keyof SessionTodoErrors] -export type SessionShellResponses = { +export type SessionTodoResponses = { /** - * Created message + * Todo list + */ + 200: Array +} + +export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses] + +export type SessionDiffData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + messageID?: string + } + url: "/session/{sessionID}/diff" +} + +export type SessionDiffResponses = { + /** + * Successfully retrieved diff */ - 200: { - info: Message - parts: Array - } + 200: Array } -export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] +export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses] -export type SessionRevertData = { - body?: { - messageID: string - partID?: string - } +export type SessionMessagesData = { + body?: never path: { sessionID: string } query?: { directory?: string workspace?: string + limit?: number + before?: string } - url: "/session/{sessionID}/revert" + url: "/session/{sessionID}/message" } -export type SessionRevertErrors = { +export type SessionMessagesErrors = { /** * Bad request */ @@ -4210,19 +5221,37 @@ export type SessionRevertErrors = { 404: NotFoundError } -export type SessionRevertError = SessionRevertErrors[keyof SessionRevertErrors] +export type SessionMessagesError = SessionMessagesErrors[keyof SessionMessagesErrors] -export type SessionRevertResponses = { +export type SessionMessagesResponses = { /** - * Updated session + * List of messages */ - 200: Session + 200: Array<{ + info: Message + parts: Array + }> } -export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses] +export type SessionMessagesResponse = SessionMessagesResponses[keyof SessionMessagesResponses] -export type SessionUnrevertData = { - body?: never +export type SessionPromptData = { + body?: { + messageID?: string + model?: { + providerID: string + modelID: string + } + agent?: string + noReply?: boolean + tools?: { + [key: string]: boolean + } + format?: OutputFormat + system?: string + variant?: string + parts: Array + } path: { sessionID: string } @@ -4230,10 +5259,10 @@ export type SessionUnrevertData = { directory?: string workspace?: string } - url: "/session/{sessionID}/unrevert" + url: "/session/{sessionID}/message" } -export type SessionUnrevertErrors = { +export type SessionPromptErrors = { /** * Bad request */ @@ -4244,33 +5273,34 @@ export type SessionUnrevertErrors = { 404: NotFoundError } -export type SessionUnrevertError = SessionUnrevertErrors[keyof SessionUnrevertErrors] +export type SessionPromptError = SessionPromptErrors[keyof SessionPromptErrors] -export type SessionUnrevertResponses = { +export type SessionPromptResponses = { /** - * Updated session + * Created message */ - 200: Session + 200: { + info: AssistantMessage + parts: Array + } } -export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses] +export type SessionPromptResponse = SessionPromptResponses[keyof SessionPromptResponses] -export type PermissionRespondData = { - body?: { - response: "once" | "always" | "reject" - } +export type SessionDeleteMessageData = { + body?: never path: { sessionID: string - permissionID: string + messageID: string } query?: { directory?: string workspace?: string } - url: "/session/{sessionID}/permissions/{permissionID}" + url: "/session/{sessionID}/message/{messageID}" } -export type PermissionRespondErrors = { +export type SessionDeleteMessageErrors = { /** * Bad request */ @@ -4281,33 +5311,31 @@ export type PermissionRespondErrors = { 404: NotFoundError } -export type PermissionRespondError = PermissionRespondErrors[keyof PermissionRespondErrors] +export type SessionDeleteMessageError = SessionDeleteMessageErrors[keyof SessionDeleteMessageErrors] -export type PermissionRespondResponses = { +export type SessionDeleteMessageResponses = { /** - * Permission processed successfully + * Successfully deleted message */ 200: boolean } -export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] +export type SessionDeleteMessageResponse = SessionDeleteMessageResponses[keyof SessionDeleteMessageResponses] -export type PermissionReplyData = { - body?: { - reply: "once" | "always" | "reject" - message?: string - } +export type SessionMessageData = { + body?: never path: { - requestID: string + sessionID: string + messageID: string } query?: { directory?: string workspace?: string } - url: "/permission/{requestID}/reply" + url: "/session/{sessionID}/message/{messageID}" } -export type PermissionReplyErrors = { +export type SessionMessageErrors = { /** * Bad request */ @@ -4318,73 +5346,56 @@ export type PermissionReplyErrors = { 404: NotFoundError } -export type PermissionReplyError = PermissionReplyErrors[keyof PermissionReplyErrors] +export type SessionMessageError = SessionMessageErrors[keyof SessionMessageErrors] -export type PermissionReplyResponses = { +export type SessionMessageResponses = { /** - * Permission processed successfully + * Message */ - 200: boolean -} - -export type PermissionReplyResponse = PermissionReplyResponses[keyof PermissionReplyResponses] - -export type PermissionListData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string + 200: { + info: Message + parts: Array } - url: "/permission" -} - -export type PermissionListResponses = { - /** - * List of pending permissions - */ - 200: Array } -export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses] +export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses] -export type QuestionListData = { - body?: never - path?: never +export type SessionForkData = { + body?: { + messageID?: string + } + path: { + sessionID: string + } query?: { directory?: string workspace?: string } - url: "/question" + url: "/session/{sessionID}/fork" } -export type QuestionListResponses = { +export type SessionForkResponses = { /** - * List of pending questions + * 200 */ - 200: Array + 200: Session } -export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses] +export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses] -export type QuestionReplyData = { - body?: { - /** - * User answers in order of questions (each answer is an array of selected labels) - */ - answers: Array - } +export type SessionAbortData = { + body?: never path: { - requestID: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/question/{requestID}/reply" + url: "/session/{sessionID}/abort" } -export type QuestionReplyErrors = { +export type SessionAbortErrors = { /** * Bad request */ @@ -4395,30 +5406,34 @@ export type QuestionReplyErrors = { 404: NotFoundError } -export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors] +export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors] -export type QuestionReplyResponses = { +export type SessionAbortResponses = { /** - * Question answered successfully + * Aborted session */ 200: boolean } -export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses] +export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses] -export type QuestionRejectData = { - body?: never +export type SessionInitData = { + body?: { + modelID: string + providerID: string + messageID: string + } path: { - requestID: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/question/{requestID}/reject" + url: "/session/{sessionID}/init" } -export type QuestionRejectErrors = { +export type SessionInitErrors = { /** * Bad request */ @@ -4429,643 +5444,691 @@ export type QuestionRejectErrors = { 404: NotFoundError } -export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] +export type SessionInitError = SessionInitErrors[keyof SessionInitErrors] -export type QuestionRejectResponses = { +export type SessionInitResponses = { /** - * Question rejected successfully + * 200 */ 200: boolean } -export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] +export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses] -export type ProviderListData = { +export type SessionUnshareData = { body?: never - path?: never + path: { + sessionID: string + } query?: { directory?: string workspace?: string } - url: "/provider" + url: "/session/{sessionID}/share" } -export type ProviderListResponses = { +export type SessionUnshareErrors = { /** - * List of providers + * Bad request */ - 200: { - all: Array - default: { - [key: string]: string - } - connected: Array - } -} - -export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses] - -export type ProviderAuthData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/provider/auth" -} - -export type ProviderAuthResponses = { + 400: BadRequestError /** - * Provider auth methods + * Not found */ - 200: { - [key: string]: Array - } + 404: NotFoundError } -export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses] +export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors] -export type ProviderOauthAuthorizeData = { - body?: { - /** - * Auth method index - */ - method: number - /** - * Prompt inputs - */ - inputs?: { - [key: string]: string - } - } +export type SessionUnshareResponses = { + /** + * Successfully unshared session + */ + 200: Session +} + +export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses] + +export type SessionShareData = { + body?: never path: { - /** - * Provider ID - */ - providerID: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/provider/{providerID}/oauth/authorize" + url: "/session/{sessionID}/share" } -export type ProviderOauthAuthorizeErrors = { +export type SessionShareErrors = { /** * Bad request */ 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError } -export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors] +export type SessionShareError = SessionShareErrors[keyof SessionShareErrors] -export type ProviderOauthAuthorizeResponses = { +export type SessionShareResponses = { /** - * Authorization URL and method + * Successfully shared session */ - 200: ProviderAuthAuthorization + 200: Session } -export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses] +export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses] -export type ProviderOauthCallbackData = { +export type SessionSummarizeData = { body?: { - /** - * Auth method index - */ - method: number - /** - * OAuth authorization code - */ - code?: string + providerID: string + modelID: string + auto?: boolean } path: { - /** - * Provider ID - */ - providerID: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/provider/{providerID}/oauth/callback" + url: "/session/{sessionID}/summarize" } -export type ProviderOauthCallbackErrors = { +export type SessionSummarizeErrors = { /** * Bad request */ 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError } -export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors] +export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors] -export type ProviderOauthCallbackResponses = { +export type SessionSummarizeResponses = { /** - * OAuth callback processed successfully + * Summarized session */ 200: boolean } -export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses] +export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] -export type SyncStartData = { - body?: never - path?: never +export type SessionPromptAsyncData = { + body?: { + messageID?: string + model?: { + providerID: string + modelID: string + } + agent?: string + noReply?: boolean + tools?: { + [key: string]: boolean + } + format?: OutputFormat + system?: string + variant?: string + parts: Array + } + path: { + sessionID: string + } query?: { directory?: string workspace?: string } - url: "/sync/start" + url: "/session/{sessionID}/prompt_async" } -export type SyncStartResponses = { +export type SessionPromptAsyncErrors = { /** - * Workspace sync started + * Bad request */ - 200: boolean + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError } -export type SyncStartResponse = SyncStartResponses[keyof SyncStartResponses] +export type SessionPromptAsyncError = SessionPromptAsyncErrors[keyof SessionPromptAsyncErrors] -export type SyncReplayData = { +export type SessionPromptAsyncResponses = { + /** + * Prompt accepted + */ + 204: void +} + +export type SessionPromptAsyncResponse = SessionPromptAsyncResponses[keyof SessionPromptAsyncResponses] + +export type SessionCommandData = { body?: { - directory: string - events: Array<{ - id: string - aggregateID: string - seq: number - type: string - data: { - [key: string]: unknown - } + messageID?: string + agent?: string + model?: string + arguments: string + command: string + variant?: string + parts?: Array<{ + id?: string + type: "file" + mime: string + filename?: string + url: string + source?: FilePartSource }> } - path?: never + path: { + sessionID: string + } query?: { directory?: string workspace?: string } - url: "/sync/replay" + url: "/session/{sessionID}/command" } -export type SyncReplayErrors = { +export type SessionCommandErrors = { /** * Bad request */ 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError } -export type SyncReplayError = SyncReplayErrors[keyof SyncReplayErrors] +export type SessionCommandError = SessionCommandErrors[keyof SessionCommandErrors] -export type SyncReplayResponses = { +export type SessionCommandResponses = { /** - * Replayed sync events + * Created message */ 200: { - sessionID: string + info: AssistantMessage + parts: Array } } -export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses] +export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses] -export type SyncHistoryListData = { +export type SessionShellData = { body?: { - [key: string]: number + messageID?: string + agent: string + model?: { + providerID: string + modelID: string + } + command: string + } + path: { + sessionID: string } - path?: never query?: { directory?: string workspace?: string } - url: "/sync/history" + url: "/session/{sessionID}/shell" } -export type SyncHistoryListErrors = { +export type SessionShellErrors = { /** * Bad request */ 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError } -export type SyncHistoryListError = SyncHistoryListErrors[keyof SyncHistoryListErrors] +export type SessionShellError = SessionShellErrors[keyof SessionShellErrors] -export type SyncHistoryListResponses = { +export type SessionShellResponses = { /** - * Sync events + * Created message */ - 200: Array<{ - id: string - aggregate_id: string - seq: number - type: string - data: { - [key: string]: unknown - } - }> + 200: { + info: Message + parts: Array + } } -export type SyncHistoryListResponse = SyncHistoryListResponses[keyof SyncHistoryListResponses] +export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] -export type FindTextData = { - body?: never - path?: never - query: { +export type SessionRevertData = { + body?: { + messageID: string + partID?: string + } + path: { + sessionID: string + } + query?: { directory?: string workspace?: string - pattern: string } - url: "/find" + url: "/session/{sessionID}/revert" } -export type FindTextResponses = { +export type SessionRevertErrors = { /** - * Matches + * Bad request */ - 200: Array<{ - path: { - text: string - } - lines: { - text: string - } - line_number: number - absolute_offset: number - submatches: Array<{ - match: { - text: string - } - start: number - end: number - }> - }> + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError } -export type FindTextResponse = FindTextResponses[keyof FindTextResponses] - -export type FindFilesData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - query: string - dirs?: "true" | "false" - type?: "file" | "directory" - limit?: number - } - url: "/find/file" -} +export type SessionRevertError = SessionRevertErrors[keyof SessionRevertErrors] -export type FindFilesResponses = { +export type SessionRevertResponses = { /** - * File paths + * Updated session */ - 200: Array + 200: Session } -export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses] +export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses] -export type FindSymbolsData = { +export type SessionUnrevertData = { body?: never - path?: never - query: { + path: { + sessionID: string + } + query?: { directory?: string workspace?: string - query: string } - url: "/find/symbol" + url: "/session/{sessionID}/unrevert" } -export type FindSymbolsResponses = { +export type SessionUnrevertErrors = { /** - * Symbols + * Bad request */ - 200: Array + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError } -export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses] - -export type FileListData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - path: string - } - url: "/file" -} +export type SessionUnrevertError = SessionUnrevertErrors[keyof SessionUnrevertErrors] -export type FileListResponses = { +export type SessionUnrevertResponses = { /** - * Files and directories + * Updated session */ - 200: Array + 200: Session } -export type FileListResponse = FileListResponses[keyof FileListResponses] +export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses] -export type FileReadData = { - body?: never - path?: never - query: { +export type PermissionRespondData = { + body?: { + response: "once" | "always" | "reject" + } + path: { + sessionID: string + permissionID: string + } + query?: { directory?: string workspace?: string - path: string } - url: "/file/content" + url: "/session/{sessionID}/permissions/{permissionID}" } -export type FileReadResponses = { +export type PermissionRespondErrors = { /** - * File content + * Bad request */ - 200: FileContent + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError } -export type FileReadResponse = FileReadResponses[keyof FileReadResponses] +export type PermissionRespondError = PermissionRespondErrors[keyof PermissionRespondErrors] -export type FileStatusData = { +export type PermissionRespondResponses = { + /** + * Permission processed successfully + */ + 200: boolean +} + +export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] + +export type PartDeleteData = { body?: never - path?: never + path: { + sessionID: string + messageID: string + partID: string + } query?: { directory?: string workspace?: string } - url: "/file/status" + url: "/session/{sessionID}/message/{messageID}/part/{partID}" } -export type FileStatusResponses = { +export type PartDeleteErrors = { /** - * File status + * Bad request */ - 200: Array + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError } -export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses] +export type PartDeleteError = PartDeleteErrors[keyof PartDeleteErrors] -export type EventSubscribeData = { - body?: never - path?: never +export type PartDeleteResponses = { + /** + * Successfully deleted part + */ + 200: boolean +} + +export type PartDeleteResponse = PartDeleteResponses[keyof PartDeleteResponses] + +export type PartUpdateData = { + body?: Part + path: { + sessionID: string + messageID: string + partID: string + } query?: { directory?: string workspace?: string } - url: "/event" + url: "/session/{sessionID}/message/{messageID}/part/{partID}" } -export type EventSubscribeResponses = { +export type PartUpdateErrors = { /** - * Event stream + * Bad request */ - 200: Event + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError } -export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses] +export type PartUpdateError = PartUpdateErrors[keyof PartUpdateErrors] -export type McpStatusData = { +export type PartUpdateResponses = { + /** + * Successfully updated part + */ + 200: Part +} + +export type PartUpdateResponse = PartUpdateResponses[keyof PartUpdateResponses] + +export type SyncStartData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/mcp" + url: "/sync/start" } -export type McpStatusResponses = { +export type SyncStartResponses = { /** - * MCP server status + * Workspace sync started */ - 200: { - [key: string]: McpStatus - } + 200: boolean } -export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses] +export type SyncStartResponse = SyncStartResponses[keyof SyncStartResponses] -export type McpAddData = { +export type SyncReplayData = { body?: { - name: string - config: McpLocalConfig | McpRemoteConfig + directory: string + events: Array<{ + id: string + aggregateID: string + seq: number + type: string + data: { + [key: string]: unknown + } + }> } path?: never query?: { directory?: string workspace?: string } - url: "/mcp" + url: "/sync/replay" } -export type McpAddErrors = { +export type SyncReplayErrors = { /** * Bad request */ 400: BadRequestError } -export type McpAddError = McpAddErrors[keyof McpAddErrors] +export type SyncReplayError = SyncReplayErrors[keyof SyncReplayErrors] -export type McpAddResponses = { +export type SyncReplayResponses = { /** - * MCP server added successfully + * Replayed sync events */ 200: { - [key: string]: McpStatus + sessionID: string } } -export type McpAddResponse = McpAddResponses[keyof McpAddResponses] +export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses] -export type McpAuthRemoveData = { - body?: never - path: { - name: string +export type SyncHistoryListData = { + body?: { + [key: string]: number } + path?: never query?: { directory?: string workspace?: string } - url: "/mcp/{name}/auth" + url: "/sync/history" } -export type McpAuthRemoveErrors = { +export type SyncHistoryListErrors = { /** - * Not found + * Bad request */ - 404: NotFoundError + 400: BadRequestError } -export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors] +export type SyncHistoryListError = SyncHistoryListErrors[keyof SyncHistoryListErrors] -export type McpAuthRemoveResponses = { +export type SyncHistoryListResponses = { /** - * OAuth credentials removed + * Sync events */ - 200: { - success: true - } + 200: Array<{ + id: string + aggregate_id: string + seq: number + type: string + data: { + [key: string]: unknown + } + }> } -export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses] +export type SyncHistoryListResponse = SyncHistoryListResponses[keyof SyncHistoryListResponses] -export type McpAuthStartData = { +export type V2SessionListData = { body?: never - path: { - name: string - } + path?: never query?: { directory?: string workspace?: string } - url: "/mcp/{name}/auth" + url: "/api/session" } -export type McpAuthStartErrors = { +export type V2SessionListErrors = { /** - * MCP server does not support OAuth - */ - 400: McpUnsupportedOAuthError - /** - * Not found + * Bad request */ - 404: NotFoundError + 400: BadRequestError } -export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors] +export type V2SessionListError = V2SessionListErrors[keyof V2SessionListErrors] -export type McpAuthStartResponses = { +export type V2SessionListResponses = { /** - * OAuth flow started + * V2SessionsResponse */ - 200: { - /** - * URL to open in browser for authorization - */ - authorizationUrl: string - } + 200: V2SessionsResponse } -export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses] +export type V2SessionListResponse = V2SessionListResponses[keyof V2SessionListResponses] -export type McpAuthCallbackData = { +export type V2SessionPromptData = { body?: { - /** - * Authorization code from OAuth callback - */ - code: string + prompt: Prompt + delivery?: SessionDelivery } path: { - name: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/mcp/{name}/auth/callback" -} - -export type McpAuthCallbackErrors = { - /** - * Bad request - */ - 400: BadRequestError - /** - * Not found - */ - 404: NotFoundError + url: "/api/session/{sessionID}/prompt" } -export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors] - -export type McpAuthCallbackResponses = { +export type V2SessionPromptResponses = { /** - * OAuth authentication completed + * Session.Message */ - 200: McpStatus + 200: SessionMessage } -export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses] +export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] -export type McpAuthAuthenticateData = { +export type V2SessionCompactData = { body?: never path: { - name: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/mcp/{name}/auth/authenticate" + url: "/api/session/{sessionID}/compact" } -export type McpAuthAuthenticateErrors = { - /** - * MCP server does not support OAuth - */ - 400: McpUnsupportedOAuthError +export type V2SessionCompactResponses = { /** - * Not found + * */ - 404: NotFoundError + 204: void } -export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors] +export type V2SessionCompactResponse = V2SessionCompactResponses[keyof V2SessionCompactResponses] -export type McpAuthAuthenticateResponses = { +export type V2SessionWaitData = { + body?: never + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/api/session/{sessionID}/wait" +} + +export type V2SessionWaitResponses = { /** - * OAuth authentication completed + * */ - 200: McpStatus + 204: void } -export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses] +export type V2SessionWaitResponse = V2SessionWaitResponses[keyof V2SessionWaitResponses] -export type McpConnectData = { +export type V2SessionContextData = { body?: never path: { - name: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/mcp/{name}/connect" + url: "/api/session/{sessionID}/context" } -export type McpConnectResponses = { +export type V2SessionContextResponses = { /** - * MCP server connected successfully + * Success */ - 200: boolean + 200: Array } -export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses] +export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] -export type McpDisconnectData = { +export type V2SessionMessagesData = { body?: never path: { - name: string + sessionID: string } query?: { directory?: string workspace?: string } - url: "/mcp/{name}/disconnect" + url: "/api/session/{sessionID}/message" } -export type McpDisconnectResponses = { +export type V2SessionMessagesErrors = { /** - * MCP server disconnected successfully + * Bad request */ - 200: boolean + 400: BadRequestError } -export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses] +export type V2SessionMessagesError = V2SessionMessagesErrors[keyof V2SessionMessagesErrors] + +export type V2SessionMessagesResponses = { + /** + * V2SessionMessagesResponse + */ + 200: V2SessionMessagesResponse +} + +export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses] export type TuiAppendPromptData = { body?: { @@ -5246,9 +6309,6 @@ export type TuiShowToastData = { title?: string message: string variant: "info" | "success" | "warning" | "error" - /** - * Duration in milliseconds - */ duration?: number } path?: never @@ -5269,7 +6329,7 @@ export type TuiShowToastResponses = { export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses] export type TuiPublishData = { - body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventTuiSessionSelect + body?: EventTuiPromptAppend2 | EventTuiCommandExecute2 | EventTuiToastShow2 | EventTuiSessionSelect2 path?: never query?: { directory?: string @@ -5374,179 +6434,202 @@ export type TuiControlResponseResponses = { export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] -export type InstanceDisposeData = { +export type ExperimentalWorkspaceAdapterListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/instance/dispose" + url: "/experimental/workspace/adapter" } -export type InstanceDisposeResponses = { +export type ExperimentalWorkspaceAdapterListResponses = { /** - * Instance disposed + * Workspace adapters */ - 200: boolean + 200: Array<{ + type: string + name: string + description: string + }> } -export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses] +export type ExperimentalWorkspaceAdapterListResponse = + ExperimentalWorkspaceAdapterListResponses[keyof ExperimentalWorkspaceAdapterListResponses] -export type PathGetData = { +export type ExperimentalWorkspaceListData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/path" + url: "/experimental/workspace" } -export type PathGetResponses = { +export type ExperimentalWorkspaceListResponses = { /** - * Path + * Workspaces */ - 200: Path + 200: Array } -export type PathGetResponse = PathGetResponses[keyof PathGetResponses] +export type ExperimentalWorkspaceListResponse = + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] -export type VcsGetData = { - body?: never +export type ExperimentalWorkspaceCreateData = { + body?: { + id?: string + type: string + branch: string | null + extra?: unknown | null + } path?: never query?: { directory?: string workspace?: string } - url: "/vcs" + url: "/experimental/workspace" } -export type VcsGetResponses = { +export type ExperimentalWorkspaceCreateErrors = { /** - * VCS info + * Bad request */ - 200: VcsInfo + 400: BadRequestError } -export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses] - -export type VcsDiffData = { - body?: never - path?: never - query: { - directory?: string - workspace?: string - mode: "git" | "branch" - } - url: "/vcs/diff" -} +export type ExperimentalWorkspaceCreateError = + ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] -export type VcsDiffResponses = { +export type ExperimentalWorkspaceCreateResponses = { /** - * VCS diff + * Workspace created */ - 200: Array + 200: Workspace } -export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses] +export type ExperimentalWorkspaceCreateResponse = + ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] -export type CommandListData = { +export type ExperimentalWorkspaceStatusData = { body?: never path?: never query?: { directory?: string workspace?: string } - url: "/command" + url: "/experimental/workspace/status" } -export type CommandListResponses = { +export type ExperimentalWorkspaceStatusResponses = { /** - * List of commands + * Workspace status */ - 200: Array + 200: Array<{ + workspaceID: string + status: "connected" | "connecting" | "disconnected" | "error" + }> } -export type CommandListResponse = CommandListResponses[keyof CommandListResponses] +export type ExperimentalWorkspaceStatusResponse = + ExperimentalWorkspaceStatusResponses[keyof ExperimentalWorkspaceStatusResponses] -export type AppAgentsData = { +export type ExperimentalWorkspaceRemoveData = { body?: never - path?: never + path: { + id: string + } query?: { directory?: string workspace?: string } - url: "/agent" + url: "/experimental/workspace/{id}" } -export type AppAgentsResponses = { +export type ExperimentalWorkspaceRemoveErrors = { /** - * List of agents + * Bad request */ - 200: Array + 400: BadRequestError } -export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses] - -export type AppSkillsData = { - body?: never - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/skill" -} +export type ExperimentalWorkspaceRemoveError = + ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] -export type AppSkillsResponses = { +export type ExperimentalWorkspaceRemoveResponses = { /** - * List of skills + * Workspace removed */ - 200: Array<{ - name: string - description: string - location: string - content: string - }> + 200: Workspace } -export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses] +export type ExperimentalWorkspaceRemoveResponse = + ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] -export type LspStatusData = { - body?: never - path?: never +export type ExperimentalWorkspaceSessionRestoreData = { + body?: { + sessionID: string + } + path: { + id: string + } query?: { directory?: string workspace?: string } - url: "/lsp" + url: "/experimental/workspace/{id}/session-restore" } -export type LspStatusResponses = { +export type ExperimentalWorkspaceSessionRestoreErrors = { /** - * LSP server status + * Bad request */ - 200: Array + 400: BadRequestError } -export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] +export type ExperimentalWorkspaceSessionRestoreError = + ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] -export type FormatterStatusData = { +export type ExperimentalWorkspaceSessionRestoreResponses = { + /** + * Session replay started + */ + 200: { + total: number + } +} + +export type ExperimentalWorkspaceSessionRestoreResponse = + ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] + +export type PtyConnectData = { body?: never - path?: never + path: { + ptyID: string + } query?: { directory?: string workspace?: string } - url: "/formatter" + url: "/pty/{ptyID}/connect" } -export type FormatterStatusResponses = { +export type PtyConnectErrors = { /** - * Formatter status + * Not found */ - 200: Array + 404: NotFoundError } -export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] +export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors] + +export type PtyConnectResponses = { + /** + * Connected session + */ + 200: boolean +} + +export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses] diff --git a/specs/v2/session-concepts-gap.md b/specs/v2/session-concepts-gap.md new file mode 100644 index 0000000000..20d84c8f47 --- /dev/null +++ b/specs/v2/session-concepts-gap.md @@ -0,0 +1,131 @@ +# Session V2 Concept Gaps + +Compared with `packages/opencode/src/session/message-v2.ts` and `packages/opencode/src/session/processor.ts`, `packages/opencode/src/v2` currently captures the rough event stream for prompts, assistant steps, text, reasoning, tools, retries, and compaction, but it does not yet capture several persisted-message and processor concepts. + +## Message Metadata + +- User messages are missing selected `agent`, `model`, `system`, enabled `tools`, output `format`, and summary metadata. +- Assistant messages are missing `parentID`, `agent`, `providerID`, `modelID`, `variant`, `path.cwd`, `path.root`, deprecated `mode`, `summary`, `structured`, `finish`, and typed `error`. + +## Output Format + +- Text output format. +- JSON-schema output format. +- Structured-output retry count. +- Structured assistant result payload. +- Structured-output error classification. + +## Errors + +- Aborted error. +- Provider auth error. +- API error with status, retryability, headers, body, and metadata. +- Context-overflow error. +- Output-length error. +- Unknown error. +- V2 mostly reduces assistant errors to strings, except retry errors. + +## Part Identity + +- V1 has stable `MessageID`, `PartID`, `sessionID`, and `messageID` on every part. +- V2 assistant content does not preserve stable per-content IDs. +- Stable content IDs matter for deltas, updates, removals, sync events, and UI reconciliation. + +## Part Timing And Metadata + +- V1 text, reasoning, and tool states carry timing and provider metadata. +- V2 assistant text and reasoning content only store text. +- V2 events include metadata, but `SessionEntry` currently drops most provider metadata. + +## Snapshots And Patches + +- Snapshot parts. +- Patch parts. +- Step-start snapshot references. +- Step-finish snapshot references. +- Processor behavior that tracks a snapshot before the stream and emits patches after step finish or cleanup. + +## Step Boundaries + +- V1 stores `step-start` and `step-finish` as first-class parts. +- V2 has `step.started` and `step.ended` events, but the assistant entry only stores aggregate cost and tokens. +- V2 does not preserve step boundary parts, finish reason, or snapshot details in the entry model. + +## Compaction + +- V1 compaction parts have `auto`, `overflow`, and `tail_start_id`. +- V2 compacted events have `auto` and optional `overflow`, but no retained-tail marker. +- V1 also has history filtering semantics around completed summary messages and retained tails. + +## Files And Sources + +- V1 file parts have `mime`, `filename`, `url`, and typed source information. +- V1 source variants include file, symbol, and resource sources. +- Symbol sources include LSP range, name, and kind. +- Resource sources include client name and URI. +- V2 file attachments have `uri`, `mime`, `name`, `description`, and a generic text source, but lose source type, LSP metadata, and resource metadata. + +## Agents And Subtasks + +- Agent parts. +- Subtask parts. +- Subtask prompt, description, agent, model, and command. +- V2 has agent attachments on prompts, but no assistant/session content equivalent for subtask execution. + +## Text Flags + +- Synthetic text flag. +- Ignored text flag. +- V2 has a separate synthetic entry, but no ignored text concept. + +## Tool Calls + +- V1 pending tool state stores parsed input and raw input text separately. +- V2 pending tool state stores a string input but does not preserve a separate raw field. +- V1 completed tool state has `time.start`, `time.end`, and optional `time.compacted`. +- V2 tool time has `created`, `ran`, `completed`, and `pruned`, but the stepper currently does not set `completed` or `pruned`. +- V1 error tool state has `time.start` and `time.end`. +- V1 supports interrupted tool errors with `metadata.interrupted` and preserved partial output. +- V1 tracks provider execution and provider call metadata. +- V2 events include provider info, but `SessionEntryStepper` drops it from entries. +- V1 has tool-output compaction and truncation behavior via `time.compacted`. + +## Media Handling + +- V1 models tool attachments as file parts and has provider-specific handling for media in tool results. +- V1 can strip media, inject synthetic user messages for unsupported providers, and uses a synthetic attachment prompt. +- V2 has attachments but not these model-message conversion semantics. + +## Retries + +- V1 stores retries as independently addressable retry parts. +- V2 stores retries as an assistant aggregate. +- V2 captures some retry information, but not the independent part identity/update model. + +## Processor Control Flow + +- Session status transitions: busy, retry, and idle. +- Retry policy integration. +- Context-overflow-driven compaction. +- Abort and interrupt handling. +- Permission-denied blocking. +- Doom-loop detection. +- Plugin hook for `experimental.text.complete`. +- Background summary generation after steps. +- Cleanup semantics for open text, reasoning, and tool calls. + +## Sync And Bus Events + +- Message updated. +- Message removed. +- Message part updated. +- Message part delta. +- Message part removed. +- V2 has domain events, but not the sync/bus event model for persisted message and part updates/removals. + +## History Retrieval + +- Cursor encoding and decoding. +- Paged message retrieval. +- Reverse streaming through history. +- Compaction-aware history filtering. From a6cadba81432997fb3ca5c848f7586c6f7b8d48b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 02:10:52 +0000 Subject: [PATCH 071/178] chore: generate --- .../snapshot.json | 144 +- .../snapshot.json | 142 +- .../20260501142318_next_venus/snapshot.json | 144 +- .../src/server/routes/instance/event.ts | 8 +- packages/opencode/src/session/processor.ts | 2 +- packages/opencode/src/session/session.ts | 6 +- .../src/v2/session-message-updater.ts | 4 +- .../test/server/httpapi-session.test.ts | 3 +- packages/sdk/openapi.json | 3506 +++++++++++++++-- 9 files changed, 3229 insertions(+), 730 deletions(-) diff --git a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json index bb6d06237e..a237b4156e 100644 --- a/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json +++ b/packages/opencode/migration/20260427172553_slow_nightmare/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "61f807f9-6398-4067-be05-804acc2561bc", - "prevIds": [ - "66cbe0d7-def0-451b-b88a-7608513a9b44" - ], + "prevIds": ["66cbe0d7-def0-451b-b88a-7608513a9b44"], "ddl": [ { "name": "account_state", @@ -1043,13 +1041,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1058,13 +1052,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1073,13 +1063,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1088,13 +1074,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1103,13 +1085,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1118,13 +1096,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1133,13 +1107,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1148,13 +1118,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1163,13 +1129,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1178,13 +1140,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1193,128 +1151,98 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1478,4 +1406,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json index 1f3bc493c1..740ba0e254 100644 --- a/packages/opencode/migration/20260428004200_add_session_path/snapshot.json +++ b/packages/opencode/migration/20260428004200_add_session_path/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "aaa2ebeb-caa4-478d-8365-4fc595d16856", - "prevIds": [ - "61f807f9-6398-4067-be05-804acc2561bc" - ], + "prevIds": ["61f807f9-6398-4067-be05-804acc2561bc"], "ddl": [ { "name": "account_state", @@ -1053,13 +1051,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1068,13 +1062,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1083,13 +1073,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1098,13 +1084,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1113,13 +1095,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1128,13 +1106,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1143,13 +1117,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1158,13 +1128,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1173,13 +1139,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1188,13 +1150,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1203,128 +1161,98 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", diff --git a/packages/opencode/migration/20260501142318_next_venus/snapshot.json b/packages/opencode/migration/20260501142318_next_venus/snapshot.json index e594de2f04..1eb0cf0b07 100644 --- a/packages/opencode/migration/20260501142318_next_venus/snapshot.json +++ b/packages/opencode/migration/20260501142318_next_venus/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "2ec89846-dcf1-4977-ab5e-244ddc9e3d67", - "prevIds": [ - "aaa2ebeb-caa4-478d-8365-4fc595d16856" - ], + "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], "ddl": [ { "name": "account_state", @@ -1073,13 +1071,9 @@ "table": "event" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1088,13 +1082,9 @@ "table": "account_state" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1103,13 +1093,9 @@ "table": "workspace" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1118,13 +1104,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1133,13 +1115,9 @@ "table": "part" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1148,13 +1126,9 @@ "table": "permission" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1163,13 +1137,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1178,13 +1148,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1193,13 +1159,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1208,13 +1170,9 @@ "table": "session_share" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1223,128 +1181,98 @@ "table": "event" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", @@ -1508,4 +1436,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/opencode/src/server/routes/instance/event.ts b/packages/opencode/src/server/routes/instance/event.ts index 52e9bc1964..aeb1da5393 100644 --- a/packages/opencode/src/server/routes/instance/event.ts +++ b/packages/opencode/src/server/routes/instance/event.ts @@ -51,10 +51,10 @@ export const EventRoutes = () => // Send heartbeat every 10s to prevent stalled proxy streams. const heartbeat = setInterval(() => { q.push( - JSON.stringify({ - id: Bus.createID(), - type: "server.heartbeat", - properties: {}, + JSON.stringify({ + id: Bus.createID(), + type: "server.heartbeat", + properties: {}, }), ) }, 10_000) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 1a32a656d1..e2a47f1800 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -377,7 +377,7 @@ export const layer: Layer.Layer< case "tool-result": { const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Success.Sync, { + EventV2.run(SessionEvent.Tool.Success.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, structured: value.output.metadata, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index fedfa8996e..09d2c8c3c3 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -81,7 +81,11 @@ export function fromRow(row: SessionRow): Info { title: row.title, agent: row.agent ?? undefined, model: row.model - ? { id: ModelID.make(row.model.id), providerID: ProviderID.make(row.model.providerID), variant: row.model.variant } + ? { + id: ModelID.make(row.model.id), + providerID: ProviderID.make(row.model.providerID), + variant: row.model.variant, + } : undefined, version: row.version, summary, diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index 844f6fe2d1..ad1aa32e70 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -89,9 +89,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve assistant?.content.findLast((item): item is DraftText => item.type === "text") const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => - assistant?.content.findLast( - (item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID, - ) + assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID) SessionEvent.All.match(event, { "session.next.agent.switched": (event) => { diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index d96347bed8..c9a0b53bb4 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -242,7 +242,8 @@ describe("session HttpApi", () => { ) expect( - (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })).items, + (yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers })) + .items, ).toMatchObject([{ type: "assistant" }]) }), ), diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 208346325b..b1c4ec1d76 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2461,6 +2461,24 @@ "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" }, @@ -7595,6 +7613,9 @@ "Event.server.instance.disposed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "server.instance.disposed" @@ -7609,11 +7630,14 @@ "required": ["directory"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.file.edited": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "file.edited" @@ -7628,11 +7652,14 @@ "required": ["file"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.file.watcher.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "file.watcher.updated" @@ -7651,11 +7678,14 @@ "required": ["file", "event"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.lsp.client.diagnostics": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "lsp.client.diagnostics" @@ -7673,11 +7703,14 @@ "required": ["serverID", "path"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.lsp.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "lsp.updated" @@ -7687,11 +7720,14 @@ "properties": {} } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.message.part.delta": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.part.delta" @@ -7721,7 +7757,7 @@ "required": ["sessionID", "messageID", "partID", "field", "delta"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "PermissionRequest": { "type": "object", @@ -7775,6 +7811,9 @@ "Event.permission.asked": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "permission.asked" @@ -7783,11 +7822,14 @@ "$ref": "#/components/schemas/PermissionRequest" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.permission.replied": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "permission.replied" @@ -7811,7 +7853,7 @@ "required": ["sessionID", "requestID", "reply"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "SnapshotFileDiff": { "type": "object", @@ -7842,6 +7884,9 @@ "Event.session.diff": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.diff" @@ -7863,7 +7908,7 @@ "required": ["sessionID", "diff"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "ProviderAuthError": { "type": "object", @@ -8036,6 +8081,9 @@ "Event.session.error": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.error" @@ -8075,11 +8123,14 @@ } } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.installation.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "installation.updated" @@ -8094,11 +8145,14 @@ "required": ["version"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.installation.update-available": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "installation.update-available" @@ -8113,7 +8167,7 @@ "required": ["version"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "QuestionOption": { "type": "object", @@ -8198,6 +8252,9 @@ "Event.question.asked": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "question.asked" @@ -8206,7 +8263,7 @@ "$ref": "#/components/schemas/QuestionRequest" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "QuestionAnswer": { "type": "array", @@ -8237,6 +8294,9 @@ "Event.question.replied": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "question.replied" @@ -8245,7 +8305,7 @@ "$ref": "#/components/schemas/QuestionReplied" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "QuestionRejected": { "type": "object", @@ -8264,6 +8324,9 @@ "Event.question.rejected": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "question.rejected" @@ -8272,7 +8335,7 @@ "$ref": "#/components/schemas/QuestionRejected" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Todo": { "type": "object", @@ -8295,6 +8358,9 @@ "Event.todo.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "todo.updated" @@ -8316,7 +8382,7 @@ "required": ["sessionID", "todos"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "SessionStatus": { "anyOf": [ @@ -8368,6 +8434,9 @@ "Event.session.status": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.status" @@ -8386,11 +8455,14 @@ "required": ["sessionID", "status"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.idle": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.idle" @@ -8406,11 +8478,14 @@ "required": ["sessionID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.compacted": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.compacted" @@ -8426,7 +8501,7 @@ "required": ["sessionID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.tui.prompt.append": { "type": "object", @@ -8547,6 +8622,9 @@ "Event.mcp.tools.changed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "mcp.tools.changed" @@ -8561,11 +8639,14 @@ "required": ["server"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.mcp.browser.open.failed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "mcp.browser.open.failed" @@ -8583,11 +8664,14 @@ "required": ["mcpName", "url"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.command.executed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "command.executed" @@ -8613,7 +8697,7 @@ "required": ["name", "sessionID", "arguments", "messageID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Project": { "type": "object", @@ -8687,6 +8771,9 @@ "Event.project.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "project.updated" @@ -8695,11 +8782,14 @@ "$ref": "#/components/schemas/Project" } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.vcs.branch.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "vcs.branch.updated" @@ -8713,11 +8803,14 @@ } } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.ready": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.ready" @@ -8732,11 +8825,14 @@ "required": ["name"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.failed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.failed" @@ -8751,11 +8847,14 @@ "required": ["message"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.restore": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.restore" @@ -8785,11 +8884,14 @@ "required": ["workspaceID", "sessionID", "total", "step"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.workspace.status": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "workspace.status" @@ -8809,11 +8911,14 @@ "required": ["workspaceID", "status"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.worktree.ready": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "worktree.ready" @@ -8831,11 +8936,14 @@ "required": ["name", "branch"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.worktree.failed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "worktree.failed" @@ -8850,7 +8958,7 @@ "required": ["message"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Pty": { "type": "object", @@ -8889,6 +8997,9 @@ "Event.pty.created": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.created" @@ -8903,11 +9014,14 @@ "required": ["info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.pty.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.updated" @@ -8922,11 +9036,14 @@ "required": ["info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.pty.exited": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.exited" @@ -8947,11 +9064,14 @@ "required": ["id", "exitCode"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.pty.deleted": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "pty.deleted" @@ -8967,7 +9087,7 @@ "required": ["id"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "OutputFormatText": { "type": "object", @@ -9263,6 +9383,9 @@ "Event.message.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.updated" @@ -9281,11 +9404,14 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.message.removed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.removed" @@ -9305,7 +9431,7 @@ "required": ["sessionID", "messageID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "TextPart": { "type": "object", @@ -10147,6 +10273,9 @@ "Event.message.part.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.part.updated" @@ -10170,11 +10299,14 @@ "required": ["sessionID", "part", "time"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.message.part.removed": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "message.part.removed" @@ -10198,7 +10330,7 @@ "required": ["sessionID", "messageID", "partID"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "PermissionAction": { "type": "string", @@ -10291,6 +10423,24 @@ "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, "version": { "type": "string" }, @@ -10347,6 +10497,9 @@ "Event.session.created": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.created" @@ -10365,11 +10518,14 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.updated": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.updated" @@ -10388,11 +10544,14 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, "Event.session.deleted": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "session.deleted" @@ -10411,502 +10570,2725 @@ "required": ["sessionID", "info"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, - "Event.server.connected": { + "Event.session.next.agent.switched": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "server.connected" + "id": { + "type": "string" }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["type", "properties"] - }, - "Event.global.disposed": { - "type": "object", - "properties": { "type": { "type": "string", - "const": "global.disposed" + "const": "session.next.agent.switched" }, "properties": { "type": "object", - "properties": {} + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"] } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"] }, - "SyncEvent.message.updated": { + "Event.session.next.model.switched": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.updated.1" - }, "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { + "type": { "type": "string", - "const": "sessionID" + "const": "session.next.model.switched" }, - "data": { + "properties": { "type": "object", "properties": { + "timestamp": { + "type": "number" + }, "sessionID": { "type": "string", "pattern": "^ses.*" }, - "info": { - "$ref": "#/components/schemas/Message" + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" } }, - "required": ["sessionID", "info"] + "required": ["timestamp", "sessionID", "id", "providerID"] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["id", "type", "properties"] }, - "SyncEvent.message.removed": { + "Prompt.Source": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" + "start": { + "type": "number" + }, + "end": { + "type": "number" + }, + "text": { + "type": "string" + } + }, + "required": ["start", "end", "text"] + }, + "Prompt.FileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" + }, + "mime": { + "type": "string" }, "name": { - "type": "string", - "const": "message.removed.1" + "type": "string" }, - "id": { + "description": { "type": "string" }, - "seq": { - "type": "number" + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": ["uri", "mime"] + }, + "Prompt.AgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" }, - "aggregateID": { + "source": { + "$ref": "#/components/schemas/Prompt.Source" + } + }, + "required": ["name"] + }, + "Prompt": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.FileAttachment" + } + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Prompt.AgentAttachment" + } + } + }, + "required": ["text"] + }, + "Event.session.next.prompted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { "type": "string", - "const": "sessionID" + "const": "session.next.prompted" }, - "data": { + "properties": { "type": "object", "properties": { + "timestamp": { + "type": "number" + }, "sessionID": { "type": "string", "pattern": "^ses.*" }, - "messageID": { - "type": "string", - "pattern": "^msg.*" + "prompt": { + "$ref": "#/components/schemas/Prompt" } }, - "required": ["sessionID", "messageID"] + "required": ["timestamp", "sessionID", "prompt"] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["id", "type", "properties"] }, - "SyncEvent.message.part.updated": { + "Event.session.next.synthetic": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" + "id": { + "type": "string" }, - "name": { + "type": { "type": "string", - "const": "message.part.updated.1" + "const": "session.next.synthetic" }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.shell.started": { + "type": "object", + "properties": { "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { + "type": { "type": "string", - "const": "sessionID" + "const": "session.next.shell.started" }, - "data": { + "properties": { "type": "object", "properties": { + "timestamp": { + "type": "number" + }, "sessionID": { "type": "string", "pattern": "^ses.*" }, - "part": { - "$ref": "#/components/schemas/Part" + "callID": { + "type": "string" }, - "time": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "command": { + "type": "string" } }, - "required": ["sessionID", "part", "time"] + "required": ["timestamp", "sessionID", "callID", "command"] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["id", "type", "properties"] }, - "SyncEvent.message.part.removed": { + "Event.session.next.shell.ended": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.part.removed.1" - }, "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { + "type": { "type": "string", - "const": "sessionID" + "const": "session.next.shell.ended" }, - "data": { + "properties": { "type": "object", "properties": { + "timestamp": { + "type": "number" + }, "sessionID": { "type": "string", "pattern": "^ses.*" }, - "messageID": { - "type": "string", - "pattern": "^msg.*" + "callID": { + "type": "string" }, - "partID": { - "type": "string", - "pattern": "^prt.*" + "output": { + "type": "string" } }, - "required": ["sessionID", "messageID", "partID"] + "required": ["timestamp", "sessionID", "callID", "output"] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["id", "type", "properties"] }, - "SyncEvent.session.created": { + "Event.session.next.step.started": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.created.1" - }, "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { + "type": { "type": "string", - "const": "sessionID" + "const": "session.next.step.started" }, - "data": { + "properties": { "type": "object", "properties": { + "timestamp": { + "type": "number" + }, "sessionID": { "type": "string", "pattern": "^ses.*" }, - "info": { - "$ref": "#/components/schemas/Session" + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, + "snapshot": { + "type": "string" } }, - "required": ["sessionID", "info"] + "required": ["timestamp", "sessionID", "agent", "model"] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["id", "type", "properties"] }, - "SyncEvent.session.updated": { + "Event.session.next.step.ended": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.updated.1" - }, "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { + "type": { "type": "string", - "const": "sessionID" + "const": "session.next.step.ended" }, - "data": { + "properties": { "type": "object", "properties": { + "timestamp": { + "type": "number" + }, "sessionID": { "type": "string", "pattern": "^ses.*" }, - "info": { + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { "type": "object", "properties": { - "id": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses.*" - }, - { - "type": "null" - } - ] - }, - "slug": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "input": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, - "projectID": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "output": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, - "workspaceID": { - "anyOf": [ - { - "type": "string", - "pattern": "^wrk.*" - }, - { - "type": "null" - } - ] - }, - "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "parentID": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses.*" - }, - { - "type": "null" - } - ] - }, - "summary": { - "anyOf": [ - { - "type": "object", - "properties": { - "additions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "deletions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "files": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["additions", "deletions", "files"] - }, - { - "type": "null" - } - ] + "reasoning": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, - "share": { + "cache": { "type": "object", "properties": { - "url": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "read": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "write": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } + }, + "required": ["read", "write"] + } + }, + "required": ["input", "output", "reasoning", "cache"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.text.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.text.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["timestamp", "sessionID"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.text.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.text.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.text.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.text.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.reasoning.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.reasoning.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.reasoning.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.reasoning.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.reasoning.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.reasoning.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.input.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.input.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.input.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.input.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.input.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.input.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.called": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.called" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"] + } + }, + "required": ["id", "type", "properties"] + }, + "Tool.TextContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "text" + }, + "text": { + "type": "string" + } + }, + "required": ["type", "text"] + }, + "Tool.FileContent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file" + }, + "uri": { + "type": "string" + }, + "mime": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["type", "uri", "mime"] + }, + "Event.session.next.tool.progress": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.progress" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.success": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.success" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.tool.error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.tool.error" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"] + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"] + } + }, + "required": ["id", "type", "properties"] + }, + "session.next.retry_error": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "statusCode": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["message", "isRetryable"] + }, + "Event.session.next.retried": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.retried" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "attempt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/components/schemas/session.next.retry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.compaction.started": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.compaction.started" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.compaction.delta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.compaction.delta" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.session.next.compaction.ended": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "session.next.compaction.ended" + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["id", "type", "properties"] + }, + "Event.server.connected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "server.connected" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"] + }, + "Event.global.disposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "const": "global.disposed" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"] + }, + "SyncEvent.message.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "message.updated.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.message.removed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "message.removed.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + } + }, + "required": ["sessionID", "messageID"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.message.part.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "message.part.updated.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["sessionID", "part", "time"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.message.part.removed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "message.part.removed.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "partID": { + "type": "string", + "pattern": "^prt.*" + } + }, + "required": ["sessionID", "messageID", "partID"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.created": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.created.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.updated.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "info": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string", + "pattern": "^ses.*" + }, + { + "type": "null" + } + ] + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspaceID": { + "anyOf": [ + { + "type": "string", + "pattern": "^wrk.*" + }, + { + "type": "null" + } + ] + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentID": { + "anyOf": [ + { + "type": "string", + "pattern": "^ses.*" + }, + { + "type": "null" + } + ] + }, + "summary": { + "anyOf": [ + { + "type": "object", + "properties": { + "additions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "deletions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "files": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"] + }, + { + "type": "null" + } + ] + }, + "share": { + "type": "object", + "properties": { + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, + { + "type": "null" + } + ] + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "updated": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "compacting": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "archived": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + } + }, + "permission": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionRuleset" + }, + { + "type": "null" + } + ] + }, + "revert": { + "anyOf": [ + { + "type": "object", + "properties": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "partID": { + "type": "string", + "pattern": "^prt.*" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"] + }, + { + "type": "null" + } + ] + } + } + } + }, + "required": ["sessionID", "info"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.deleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.deleted.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.agent.switched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.agent.switched.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.model.switched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.model.switched.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "id", "providerID"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.prompted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.prompted.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.synthetic": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.synthetic.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.shell.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.shell.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.shell.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.shell.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.step.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.step.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.step.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.step.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "output": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "reasoning": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "write": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["read", "write"] + } + }, + "required": ["input", "output", "reasoning", "cache"] + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.text.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.text.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["timestamp", "sessionID"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.text.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.text.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.text.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.text.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.reasoning.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.reasoning.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.reasoning.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.reasoning.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.reasoning.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.reasoning.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.input.started": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.input.started.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.input.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.input.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.input.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.input.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.called": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.called.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.progress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.progress.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.success": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.success.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "structured": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/Tool.TextContent" + }, + { + "$ref": "#/components/schemas/Tool.FileContent" } + ] + } + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" }, - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "time": { + "metadata": { "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "updated": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "compacting": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "archived": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - } + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.tool.error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.tool.error.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "callID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" }, - "permission": { - "anyOf": [ - { - "$ref": "#/components/schemas/PermissionRuleset" - }, - { - "type": "null" - } - ] + "message": { + "type": "string" + } + }, + "required": ["type", "message"] + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" }, - "revert": { - "anyOf": [ - { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"] - }, - { - "type": "null" - } - ] + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} } - } + }, + "required": ["executed"] + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.retried": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.retried.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "attempt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "error": { + "$ref": "#/components/schemas/session.next.retry_error" } }, - "required": ["sessionID", "info"] + "required": ["timestamp", "sessionID", "attempt", "error"] } }, "required": ["type", "name", "id", "seq", "aggregateID", "data"] }, - "SyncEvent.session.deleted": { + "SyncEvent.session.next.compaction.started": { "type": "object", "properties": { "type": { @@ -10915,7 +13297,7 @@ }, "name": { "type": "string", - "const": "session.deleted.1" + "const": "session.next.compaction.started.1" }, "id": { "type": "string" @@ -10930,15 +13312,102 @@ "data": { "type": "object", "properties": { + "timestamp": { + "type": "number" + }, "sessionID": { "type": "string", "pattern": "^ses.*" }, - "info": { - "$ref": "#/components/schemas/Session" + "reason": { + "type": "string", + "enum": ["auto", "manual"] } }, - "required": ["sessionID", "info"] + "required": ["timestamp", "sessionID", "reason"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.compaction.delta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.compaction.delta.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"] + }, + "SyncEvent.session.next.compaction.ended": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "sync" + }, + "name": { + "type": "string", + "const": "session.next.compaction.ended.1" + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "const": "sessionID" + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"] } }, "required": ["type", "name", "id", "seq", "aggregateID", "data"] @@ -11092,6 +13561,81 @@ { "$ref": "#/components/schemas/Event.session.deleted" }, + { + "$ref": "#/components/schemas/Event.session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.model.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.prompted" + }, + { + "$ref": "#/components/schemas/Event.session.next.synthetic" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.called" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.success" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.error" + }, + { + "$ref": "#/components/schemas/Event.session.next.retried" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.ended" + }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -11118,6 +13662,81 @@ }, { "$ref": "#/components/schemas/SyncEvent.session.deleted" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.model.switched" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.prompted" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.synthetic" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.shell.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.step.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.step.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.text.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.text.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.text.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.called" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.success" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.tool.error" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.retried" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.compaction.delta" + }, + { + "$ref": "#/components/schemas/SyncEvent.session.next.compaction.ended" } ] } @@ -12749,6 +15368,24 @@ "title": { "type": "string" }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"] + }, "version": { "type": "string" }, @@ -13387,6 +16024,81 @@ { "$ref": "#/components/schemas/Event.session.deleted" }, + { + "$ref": "#/components/schemas/Event.session.next.agent.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.model.switched" + }, + { + "$ref": "#/components/schemas/Event.session.next.prompted" + }, + { + "$ref": "#/components/schemas/Event.session.next.synthetic" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.shell.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.step.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.text.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.reasoning.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.input.ended" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.called" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.progress" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.success" + }, + { + "$ref": "#/components/schemas/Event.session.next.tool.error" + }, + { + "$ref": "#/components/schemas/Event.session.next.retried" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.started" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.delta" + }, + { + "$ref": "#/components/schemas/Event.session.next.compaction.ended" + }, { "$ref": "#/components/schemas/Event.server.connected" }, From ad05a46d747bad0c03a511ccef1115ee95a997c6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:26:54 -0400 Subject: [PATCH 072/178] refactor(lifecycle): bootstrap as pure orchestration (#25510) --- packages/opencode/src/file/watcher.ts | 6 ++-- packages/opencode/src/project/bootstrap.ts | 18 +++++------- packages/opencode/src/project/project.ts | 29 ++++++++++++++++++- .../opencode/test/project/project.test.ts | 2 ++ 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index b68c3a3356..146d7b4d07 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -123,7 +123,9 @@ export const layer = Layer.effect( const cfgIgnores = cfg.watcher?.ignore ?? [] if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { - yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]) + yield* Effect.forkScoped( + subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)]), + ) } if (ctx.project.vcs === "git") { @@ -135,7 +137,7 @@ export const layer = Layer.effect( const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", ) - yield* subscribe(vcsDir, ignore) + yield* Effect.forkScoped(subscribe(vcsDir, ignore)) } } }, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index ea2aa2e848..fb3e1bb32d 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -6,7 +6,6 @@ import { Snapshot } from "../snapshot" import * as Project from "./project" import * as Vcs from "./vcs" import { Bus } from "../bus" -import { Command } from "../command" import { InstanceState } from "@/effect/instance-state" import { FileWatcher } from "@/file/watcher" import { ShareNext } from "@/share/share-next" @@ -23,13 +22,13 @@ export const layer = Layer.effect( // Yield each bootstrap dep at layer init so `run` itself has R = never. // InstanceStore imports only the lightweight tag from bootstrap-service.ts, // so it can depend on bootstrap without importing this implementation graph. - const bus = yield* Bus.Service const config = yield* Config.Service const file = yield* File.Service const fileWatcher = yield* FileWatcher.Service const format = yield* Format.Service const lsp = yield* LSP.Service const plugin = yield* Plugin.Service + const project = yield* Project.Service const shareNext = yield* ShareNext.Service const snapshot = yield* Snapshot.Service const vcs = yield* Vcs.Service @@ -41,16 +40,13 @@ export const layer = Layer.effect( yield* config.get() // Plugin can mutate config so it has to be initialized before anything else. yield* plugin.init() - yield* Effect.all( - [lsp, shareNext, format, file, fileWatcher, vcs, snapshot].map((s) => Effect.forkDetach(s.init())), + // Each service self-manages its own slow work via Effect.forkScoped against + // its per-instance state scope. We just await materialization here. + yield* Effect.forEach( + [lsp, shareNext, format, file, fileWatcher, vcs, snapshot, project], + (s) => s.init().pipe(Effect.catchCause((cause) => Effect.logWarning("init failed", { cause }))), + { concurrency: "unbounded", discard: true }, ).pipe(Effect.withSpan("InstanceBootstrap.init")) - - const projectID = ctx.project.id - yield* bus.subscribeCallback(Command.Event.Executed, async (payload) => { - if (payload.properties.name === Command.Default.INIT) { - Project.setInitialized(projectID) - } - }) }).pipe(Effect.withSpan("InstanceBootstrap")) return Service.of({ run }) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f30d2e90c7..a2c1a097b1 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -10,6 +10,9 @@ import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { which } from "../util/which" import { ProjectID } from "./schema" +import { Bus } from "@/bus" +import { Command } from "@/command" +import { InstanceState } from "@/effect/instance-state" import { Effect, Layer, Path, Scope, Context, Stream, Types, Schema } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { NodePath } from "@effect/platform-node" @@ -108,6 +111,12 @@ export type UpdatePayload = Types.DeepMutable Effect.Effect readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }> readonly discover: (input: Info) => Effect.Effect readonly list: () => Effect.Effect @@ -127,13 +136,14 @@ type GitResult = { code: number; text: string; stderr: string } export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Bus.Service > = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service const pathSvc = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const bus = yield* Bus.Service const git = Effect.fnUntraced( function* (args: string[], opts?: { cwd?: string }) { @@ -417,6 +427,21 @@ export const layer: Layer.Layer< ) }) + const initState = yield* InstanceState.make( + Effect.fn("Project.initState")(function* (ctx) { + yield* bus.subscribe(Command.Event.Executed).pipe( + Stream.runForEach((payload) => + payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void, + ), + Effect.forkScoped, + ) + }), + ) + + const init = Effect.fn("Project.init")(function* () { + yield* InstanceState.get(initState) + }) + const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) { const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) return [] @@ -466,6 +491,7 @@ export const layer: Layer.Layer< }) return Service.of({ + init, fromDirectory, discover, list, @@ -481,6 +507,7 @@ export const layer: Layer.Layer< ) export const defaultLayer = layer.pipe( + Layer.provide(Bus.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index e69b8e6df2..9906b31645 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Bus } from "@/bus" import { Project } from "@/project/project" import * as Log from "@opencode-ai/core/util/log" import { $ } from "bun" @@ -63,6 +64,7 @@ function mockGitFailure(failArg: string) { function projectLayerWithFailure(failArg: string) { return Project.layer.pipe( Layer.provide(mockGitFailure(failArg)), + Layer.provide(Bus.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer), ) From c4311dda3125256e1207a8d1f130e8d0d3fde7b2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:27:41 -0400 Subject: [PATCH 073/178] feat(cli): allow effectCmd instance to be a function of args (#25517) --- packages/opencode/src/cli/effect-cmd.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index 94ad0232cf..ceb52d07ad 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -37,10 +37,14 @@ interface EffectCmdOpts { * directly under AppRuntime — it can yield any `AppServices` but must not * yield `InstanceRef` (it'd be undefined, causing a defect). * + * Function form: `(args) => boolean` decides per-invocation. Useful for + * commands like `run --attach ` where one flag flips between local + * (needs instance) and remote (doesn't). + * * Use `false` for commands that don't read project state (e.g. `models`, * `serve`, `web`, `account`, `db`, `upgrade`). */ - instance?: boolean + instance?: boolean | ((args: Args) => boolean) /** Defaults to process.cwd(). Override for commands that take a directory positional. */ directory?: (args: Args) => string handler: (args: Args) => Effect.Effect @@ -72,7 +76,8 @@ export const effectCmd = (opts: EffectCmdOpts) => async handler(rawArgs) { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. const args = rawArgs as unknown as Args - if (opts.instance === false) { + const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false + if (!useInstance) { await AppRuntime.runPromise(opts.handler(args)) return } From 2829943ad15da5cd736a7f70f45f54daf488bcdd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:31:20 -0400 Subject: [PATCH 074/178] refactor(cli): convert debug wait, agent list, acp to effectCmd (#25518) --- packages/opencode/src/cli/cmd/acp.ts | 89 ++++++++++---------- packages/opencode/src/cli/cmd/agent.ts | 35 ++++---- packages/opencode/src/cli/cmd/debug/index.ts | 21 ++--- 3 files changed, 72 insertions(+), 73 deletions(-) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 9095984fe3..87671f5a00 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -1,6 +1,6 @@ import * as Log from "@opencode-ai/core/util/log" -import { bootstrap } from "../bootstrap" -import { cmd } from "./cmd" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" import { Server } from "@/server/server" @@ -9,7 +9,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network" const log = Log.create({ service: "acp-command" }) -export const AcpCommand = cmd({ +export const AcpCommand = effectCmd({ command: "acp", describe: "start ACP (Agent Client Protocol) server", builder: (yargs) => { @@ -19,52 +19,53 @@ export const AcpCommand = cmd({ default: process.cwd(), }) }, - handler: async (args) => { + handler: Effect.fn("Cli.acp")(function* (args) { process.env.OPENCODE_CLIENT = "acp" - await bootstrap(process.cwd(), async () => { - const opts = await resolveNetworkOptions(args) - const server = await Server.listen(opts) + const opts = yield* Effect.promise(() => resolveNetworkOptions(args)) + const server = yield* Effect.promise(() => Server.listen(opts)) - const sdk = createOpencodeClient({ - baseUrl: `http://${server.hostname}:${server.port}`, - }) + const sdk = createOpencodeClient({ + baseUrl: `http://${server.hostname}:${server.port}`, + }) - const input = new WritableStream({ - write(chunk) { - return new Promise((resolve, reject) => { - process.stdout.write(chunk, (err) => { - if (err) { - reject(err) - } else { - resolve() - } - }) - }) - }, - }) - const output = new ReadableStream({ - start(controller) { - process.stdin.on("data", (chunk: Buffer) => { - controller.enqueue(new Uint8Array(chunk)) + const input = new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + process.stdout.write(chunk, (err) => { + if (err) { + reject(err) + } else { + resolve() + } }) - process.stdin.on("end", () => controller.close()) - process.stdin.on("error", (err) => controller.error(err)) - }, - }) + }) + }, + }) + const output = new ReadableStream({ + start(controller) { + process.stdin.on("data", (chunk: Buffer) => { + controller.enqueue(new Uint8Array(chunk)) + }) + process.stdin.on("end", () => controller.close()) + process.stdin.on("error", (err) => controller.error(err)) + }, + }) - const stream = ndJsonStream(input, output) - const agent = await ACP.init({ sdk }) + const stream = ndJsonStream(input, output) + const agent = yield* Effect.promise(() => ACP.init({ sdk })) - new AgentSideConnection((conn) => { - return agent.create(conn, { sdk }) - }, stream) + new AgentSideConnection((conn) => { + return agent.create(conn, { sdk }) + }, stream) - log.info("setup connection") - process.stdin.resume() - await new Promise((resolve, reject) => { - process.stdin.on("end", resolve) - process.stdin.on("error", reject) - }) - }) - }, + log.info("setup connection") + process.stdin.resume() + yield* Effect.promise( + () => + new Promise((resolve, reject) => { + process.stdin.on("end", () => resolve()) + process.stdin.on("error", reject) + }), + ) + }), }) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 11a6c7f430..4011269495 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -13,6 +13,8 @@ import { Instance } from "../../project/instance" import { WithInstance } from "../../project/with-instance" import { EOL } from "os" import type { Argv } from "yargs" +import { Effect } from "effect" +import { effectCmd } from "../effect-cmd" type AgentMode = "all" | "primary" | "subagent" @@ -233,28 +235,23 @@ const AgentCreateCommand = cmd({ }, }) -const AgentListCommand = cmd({ +const AgentListCommand = effectCmd({ command: "list", describe: "list all available agents", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) - const sortedAgents = agents.sort((a, b) => { - if (a.native !== b.native) { - return a.native ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - - for (const agent of sortedAgents) { - process.stdout.write(`${agent.name} (${agent.mode})` + EOL) - process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) - } - }, + handler: Effect.fn("Cli.agent.list")(function* () { + const agents = yield* Agent.Service.use((svc) => svc.list()) + const sortedAgents = agents.sort((a, b) => { + if (a.native !== b.native) { + return a.native ? -1 : 1 + } + return a.name.localeCompare(b.name) }) - }, + + for (const agent of sortedAgents) { + process.stdout.write(`${agent.name} (${agent.mode})` + EOL) + process.stdout.write(` ${JSON.stringify(agent.permission, null, 2)}` + EOL) + } + }), }) export const AgentCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 194e66b1f2..2603663fb4 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -1,5 +1,6 @@ import { Global } from "@opencode-ai/core/global" -import { bootstrap } from "../../bootstrap" +import { Duration, Effect } from "effect" +import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { ConfigCommand } from "./config" import { FileCommand } from "./file" @@ -26,19 +27,19 @@ export const DebugCommand = cmd({ .command(StartupCommand) .command(AgentCommand) .command(PathsCommand) - .command({ - command: "wait", - describe: "wait indefinitely (for debugging)", - async handler() { - await bootstrap(process.cwd(), async () => { - await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24)) - }) - }, - }) + .command(WaitCommand) .demandCommand(), async handler() {}, }) +const WaitCommand = effectCmd({ + command: "wait", + describe: "wait indefinitely (for debugging)", + handler: Effect.fn("Cli.debug.wait")(function* () { + yield* Effect.sleep(Duration.days(1)) + }), +}) + const PathsCommand = cmd({ command: "paths", describe: "show global paths (data, config, cache, state)", From 7409dcc6bdd2329c12b7053d0476bd8802747e7f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:35:20 -0400 Subject: [PATCH 075/178] refactor(cli): convert run command to effectCmd (#25519) --- packages/opencode/src/cli/cmd/cmd.ts | 2 +- packages/opencode/src/cli/cmd/run.ts | 37 ++++++++++++++----------- packages/opencode/src/cli/effect-cmd.ts | 6 ++-- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/cmd.ts b/packages/opencode/src/cli/cmd/cmd.ts index fe6d62d7b6..05af009b88 100644 --- a/packages/opencode/src/cli/cmd/cmd.ts +++ b/packages/opencode/src/cli/cmd/cmd.ts @@ -1,6 +1,6 @@ import type { CommandModule } from "yargs" -type WithDoubleDash = T & { "--"?: string[] } +export type WithDoubleDash = T & { "--"?: string[] } export function cmd(input: CommandModule>) { return input diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index f73ca67175..72096dba31 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,10 +1,10 @@ import type { Argv } from "yargs" import path from "path" import { pathToFileURL } from "url" +import { Effect } from "effect" import { UI } from "../ui" -import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { Flag } from "@opencode-ai/core/flag/flag" -import { bootstrap } from "../bootstrap" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" @@ -203,11 +203,17 @@ function normalizePath(input?: string) { return input } -export const RunCommand = cmd({ +export const RunCommand = effectCmd({ command: "run [message..]", describe: "run opencode with a message", - builder: (yargs: Argv) => { - return yargs + // --attach connects to a remote server (no local instance needed); the + // default path runs an in-process server and needs the project instance. + instance: (args) => !args.attach, + // For --dir without --attach, load instance for the resolved target dir. + // The handler also chdirs (preserving the legacy order: chdir → file resolution). + directory: (args) => (args.dir && !args.attach ? path.resolve(process.cwd(), args.dir) : process.cwd()), + builder: (yargs: Argv) => + yargs .positional("message", { describe: "message to send", type: "string", @@ -291,9 +297,9 @@ export const RunCommand = cmd({ type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, - }) - }, - handler: async (args) => { + }), + handler: Effect.fn("Cli.run")(function* (args) { + yield* Effect.promise(async () => { let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") @@ -661,13 +667,12 @@ export const RunCommand = cmd({ return await execute(sdk) } - await bootstrap(process.cwd(), async () => { - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init) - return Server.Default().app.fetch(request) - }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) - await execute(sdk) + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + await execute(sdk) }) - }, + }), }) diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index ceb52d07ad..b0f6de16b7 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -3,7 +3,7 @@ import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceRef } from "@/effect/instance-ref" -import { cmd } from "./cmd/cmd" +import { cmd, type WithDoubleDash } from "./cmd/cmd" /** * User-visible command failure. Throw via `fail("...")` from an effectCmd handler @@ -47,7 +47,7 @@ interface EffectCmdOpts { instance?: boolean | ((args: Args) => boolean) /** Defaults to process.cwd(). Override for commands that take a directory positional. */ directory?: (args: Args) => string - handler: (args: Args) => Effect.Effect + handler: (args: WithDoubleDash) => Effect.Effect } /** @@ -75,7 +75,7 @@ export const effectCmd = (opts: EffectCmdOpts) => builder: opts.builder as never, async handler(rawArgs) { // yargs typing wraps Args in ArgumentsCamelCase>; cast at the boundary. - const args = rawArgs as unknown as Args + const args = rawArgs as unknown as WithDoubleDash const useInstance = typeof opts.instance === "function" ? opts.instance(args) : opts.instance !== false if (!useInstance) { await AppRuntime.runPromise(opts.handler(args)) From 61150f63917a893e1b09c9eb1dbced7c7131fb34 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 02:36:41 +0000 Subject: [PATCH 076/178] chore: generate --- packages/opencode/src/cli/cmd/run.ts | 606 +++++++++++++-------------- 1 file changed, 303 insertions(+), 303 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 72096dba31..75f68e8ea0 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -300,288 +300,310 @@ export const RunCommand = effectCmd({ }), handler: Effect.fn("Cli.run")(function* (args) { yield* Effect.promise(async () => { - let message = [...args.message, ...(args["--"] || [])] - .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) - .join(" ") - - const directory = (() => { - if (!args.dir) return undefined - if (args.attach) return args.dir - try { - process.chdir(args.dir) - return process.cwd() - } catch { - UI.error("Failed to change directory to " + args.dir) - process.exit(1) - } - })() - - const files: { type: "file"; url: string; filename: string; mime: string }[] = [] - if (args.file) { - const list = Array.isArray(args.file) ? args.file : [args.file] + let message = [...args.message, ...(args["--"] || [])] + .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) + .join(" ") - for (const filePath of list) { - const resolvedPath = path.resolve(process.cwd(), filePath) - if (!(await Filesystem.exists(resolvedPath))) { - UI.error(`File not found: ${filePath}`) + const directory = (() => { + if (!args.dir) return undefined + if (args.attach) return args.dir + try { + process.chdir(args.dir) + return process.cwd() + } catch { + UI.error("Failed to change directory to " + args.dir) process.exit(1) } + })() - const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" + const files: { type: "file"; url: string; filename: string; mime: string }[] = [] + if (args.file) { + const list = Array.isArray(args.file) ? args.file : [args.file] - files.push({ - type: "file", - url: pathToFileURL(resolvedPath).href, - filename: path.basename(resolvedPath), - mime, - }) + for (const filePath of list) { + const resolvedPath = path.resolve(process.cwd(), filePath) + if (!(await Filesystem.exists(resolvedPath))) { + UI.error(`File not found: ${filePath}`) + process.exit(1) + } + + const mime = (await Filesystem.isDir(resolvedPath)) ? "application/x-directory" : "text/plain" + + files.push({ + type: "file", + url: pathToFileURL(resolvedPath).href, + filename: path.basename(resolvedPath), + mime, + }) + } } - } - - if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) - - if (message.trim().length === 0 && !args.command) { - UI.error("You must provide a message or a command") - process.exit(1) - } - - if (args.fork && !args.continue && !args.session) { - UI.error("--fork requires --continue or --session") - process.exit(1) - } - - const rules: Permission.Ruleset = [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - { - permission: "plan_enter", - action: "deny", - pattern: "*", - }, - { - permission: "plan_exit", - action: "deny", - pattern: "*", - }, - ] - - function title() { - if (args.title === undefined) return - if (args.title !== "") return args.title - return message.slice(0, 50) + (message.length > 50 ? "..." : "") - } - - async function session(sdk: OpencodeClient) { - const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session - - if (baseID && args.fork) { - const forked = await sdk.session.fork({ sessionID: baseID }) - return forked.data?.id + + if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text()) + + if (message.trim().length === 0 && !args.command) { + UI.error("You must provide a message or a command") + process.exit(1) } - if (baseID) return baseID + if (args.fork && !args.continue && !args.session) { + UI.error("--fork requires --continue or --session") + process.exit(1) + } - const name = title() - const result = await sdk.session.create({ title: name, permission: rules }) - return result.data?.id - } + const rules: Permission.Ruleset = [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + { + permission: "plan_enter", + action: "deny", + pattern: "*", + }, + { + permission: "plan_exit", + action: "deny", + pattern: "*", + }, + ] + + function title() { + if (args.title === undefined) return + if (args.title !== "") return args.title + return message.slice(0, 50) + (message.length > 50 ? "..." : "") + } - async function share(sdk: OpencodeClient, sessionID: string) { - const cfg = await sdk.config.get() - if (!cfg.data) return - if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return - const res = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) + async function session(sdk: OpencodeClient) { + const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session + + if (baseID && args.fork) { + const forked = await sdk.session.fork({ sessionID: baseID }) + return forked.data?.id } - return { error } - }) - if (!res.error && "data" in res && res.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) + + if (baseID) return baseID + + const name = title() + const result = await sdk.session.create({ title: name, permission: rules }) + return result.data?.id } - } - async function execute(sdk: OpencodeClient) { - function tool(part: ToolPart) { - try { - if (part.tool === ShellID.ToolID) return shell(props(part)) - if (part.tool === "glob") return glob(props(part)) - if (part.tool === "grep") return grep(props(part)) - if (part.tool === "read") return read(props(part)) - if (part.tool === "write") return write(props(part)) - if (part.tool === "webfetch") return webfetch(props(part)) - if (part.tool === "edit") return edit(props(part)) - if (part.tool === "websearch") return websearch(props(part)) - if (part.tool === "task") return task(props(part)) - if (part.tool === "todowrite") return todo(props(part)) - if (part.tool === "skill") return skill(props(part)) - return fallback(part) - } catch { - return fallback(part) + async function share(sdk: OpencodeClient, sessionID: string) { + const cfg = await sdk.config.get() + if (!cfg.data) return + if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return + const res = await sdk.session.share({ sessionID }).catch((error) => { + if (error instanceof Error && error.message.includes("disabled")) { + UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) + } + return { error } + }) + if (!res.error && "data" in res && res.data?.share?.url) { + UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) } } - function emit(type: string, data: Record) { - if (args.format === "json") { - process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) - return true + async function execute(sdk: OpencodeClient) { + function tool(part: ToolPart) { + try { + if (part.tool === ShellID.ToolID) return shell(props(part)) + if (part.tool === "glob") return glob(props(part)) + if (part.tool === "grep") return grep(props(part)) + if (part.tool === "read") return read(props(part)) + if (part.tool === "write") return write(props(part)) + if (part.tool === "webfetch") return webfetch(props(part)) + if (part.tool === "edit") return edit(props(part)) + if (part.tool === "websearch") return websearch(props(part)) + if (part.tool === "task") return task(props(part)) + if (part.tool === "todowrite") return todo(props(part)) + if (part.tool === "skill") return skill(props(part)) + return fallback(part) + } catch { + return fallback(part) + } } - return false - } - const events = await sdk.event.subscribe() - let error: string | undefined - - async function loop() { - const toggles = new Map() - - for await (const event of events.stream) { - if ( - event.type === "message.updated" && - event.properties.info.role === "assistant" && - args.format !== "json" && - toggles.get("start") !== true - ) { - UI.empty() - UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) - UI.empty() - toggles.set("start", true) + function emit(type: string, data: Record) { + if (args.format === "json") { + process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) + return true } + return false + } - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID !== sessionID) continue + const events = await sdk.event.subscribe() + let error: string | undefined - if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { - if (emit("tool_use", { part })) continue - if (part.state.status === "completed") { - tool(part) - continue - } - inline({ - icon: "✗", - title: `${part.tool} failed`, - }) - UI.error(part.state.error) - } + async function loop() { + const toggles = new Map() + for await (const event of events.stream) { if ( - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - args.format !== "json" + event.type === "message.updated" && + event.properties.info.role === "assistant" && + args.format !== "json" && + toggles.get("start") !== true ) { - if (toggles.get(part.id) === true) continue - task(props(part)) - toggles.set(part.id, true) + UI.empty() + UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`) + UI.empty() + toggles.set("start", true) } - if (part.type === "step-start") { - if (emit("step_start", { part })) continue - } + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.sessionID !== sessionID) continue + + if (part.type === "tool" && (part.state.status === "completed" || part.state.status === "error")) { + if (emit("tool_use", { part })) continue + if (part.state.status === "completed") { + tool(part) + continue + } + inline({ + icon: "✗", + title: `${part.tool} failed`, + }) + UI.error(part.state.error) + } - if (part.type === "step-finish") { - if (emit("step_finish", { part })) continue - } + if ( + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + args.format !== "json" + ) { + if (toggles.get(part.id) === true) continue + task(props(part)) + toggles.set(part.id, true) + } - if (part.type === "text" && part.time?.end) { - if (emit("text", { part })) continue - const text = part.text.trim() - if (!text) continue - if (!process.stdout.isTTY) { - process.stdout.write(text + EOL) - continue + if (part.type === "step-start") { + if (emit("step_start", { part })) continue } - UI.empty() - UI.println(text) - UI.empty() - } - if (part.type === "reasoning" && part.time?.end && args.thinking) { - if (emit("reasoning", { part })) continue - const text = part.text.trim() - if (!text) continue - const line = `Thinking: ${text}` - if (process.stdout.isTTY) { + if (part.type === "step-finish") { + if (emit("step_finish", { part })) continue + } + + if (part.type === "text" && part.time?.end) { + if (emit("text", { part })) continue + const text = part.text.trim() + if (!text) continue + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + continue + } UI.empty() - UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.println(text) UI.empty() - continue } - process.stdout.write(line + EOL) + + if (part.type === "reasoning" && part.time?.end && args.thinking) { + if (emit("reasoning", { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (process.stdout.isTTY) { + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + continue + } + process.stdout.write(line + EOL) + } } - } - if (event.type === "session.error") { - const props = event.properties - if (props.sessionID !== sessionID || !props.error) continue - let err = String(props.error.name) - if ("data" in props.error && props.error.data && "message" in props.error.data) { - err = String(props.error.data.message) + if (event.type === "session.error") { + const props = event.properties + if (props.sessionID !== sessionID || !props.error) continue + let err = String(props.error.name) + if ("data" in props.error && props.error.data && "message" in props.error.data) { + err = String(props.error.data.message) + } + error = error ? error + EOL + err : err + if (emit("error", { error: props.error })) continue + UI.error(err) + } + + if ( + event.type === "session.status" && + event.properties.sessionID === sessionID && + event.properties.status.type === "idle" + ) { + break } - error = error ? error + EOL + err : err - if (emit("error", { error: props.error })) continue - UI.error(err) - } - if ( - event.type === "session.status" && - event.properties.sessionID === sessionID && - event.properties.status.type === "idle" - ) { - break + if (event.type === "permission.asked") { + const permission = event.properties + if (permission.sessionID !== sessionID) continue + + if (args["dangerously-skip-permissions"]) { + await sdk.permission.reply({ + requestID: permission.id, + reply: "once", + }) + } else { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + ) + await sdk.permission.reply({ + requestID: permission.id, + reply: "reject", + }) + } + } } + } - if (event.type === "permission.asked") { - const permission = event.properties - if (permission.sessionID !== sessionID) continue + // Validate agent if specified + const agent = await (async () => { + if (!args.agent) return undefined + const name = args.agent - if (args["dangerously-skip-permissions"]) { - await sdk.permission.reply({ - requestID: permission.id, - reply: "once", - }) - } else { + // When attaching, validate against the running server instead of local Instance state. + if (args.attach) { + const modes = await sdk.app + .agents(undefined, { throwOnError: true }) + .then((x) => x.data ?? []) + .catch(() => undefined) + + if (!modes) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL + - `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`, + UI.Style.TEXT_NORMAL, + `failed to list agents from ${args.attach}. Falling back to default agent`, ) - await sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - }) + return undefined } - } - } - } - // Validate agent if specified - const agent = await (async () => { - if (!args.agent) return undefined - const name = args.agent + const agent = modes.find((a) => a.name === name) + if (!agent) { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" not found. Falling back to default agent`, + ) + return undefined + } - // When attaching, validate against the running server instead of local Instance state. - if (args.attach) { - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + if (agent.mode === "subagent") { + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL, + `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, + ) + return undefined + } - if (!modes) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `failed to list agents from ${args.attach}. Falling back to default agent`, - ) - return undefined + return name } - const agent = modes.find((a) => a.name === name) - if (!agent) { + const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) + if (!entry) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -589,8 +611,7 @@ export const RunCommand = effectCmd({ ) return undefined } - - if (agent.mode === "subagent") { + if (entry.mode === "subagent") { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, @@ -598,81 +619,60 @@ export const RunCommand = effectCmd({ ) return undefined } - return name - } + })() - const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) - if (!entry) { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" not found. Falling back to default agent`, - ) - return undefined - } - if (entry.mode === "subagent") { - UI.println( - UI.Style.TEXT_WARNING_BOLD + "!", - UI.Style.TEXT_NORMAL, - `agent "${name}" is a subagent, not a primary agent. Falling back to default agent`, - ) - return undefined + const sessionID = await session(sdk) + if (!sessionID) { + UI.error("Session not found") + process.exit(1) } - return name - })() + await share(sdk, sessionID) - const sessionID = await session(sdk) - if (!sessionID) { - UI.error("Session not found") - process.exit(1) - } - await share(sdk, sessionID) + loop().catch((e) => { + console.error(e) + process.exit(1) + }) - loop().catch((e) => { - console.error(e) - process.exit(1) - }) + if (args.command) { + await sdk.session.command({ + sessionID, + agent, + model: args.model, + command: args.command, + arguments: message, + variant: args.variant, + }) + } else { + const model = args.model ? Provider.parseModel(args.model) : undefined + await sdk.session.prompt({ + sessionID, + agent, + model, + variant: args.variant, + parts: [...files, { type: "text", text: message }], + }) + } + } - if (args.command) { - await sdk.session.command({ - sessionID, - agent, - model: args.model, - command: args.command, - arguments: message, - variant: args.variant, - }) - } else { - const model = args.model ? Provider.parseModel(args.model) : undefined - await sdk.session.prompt({ - sessionID, - agent, - model, - variant: args.variant, - parts: [...files, { type: "text", text: message }], - }) + if (args.attach) { + const headers = (() => { + const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" + const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` + return { Authorization: auth } + })() + const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) + return await execute(sdk) } - } - - if (args.attach) { - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" - const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } - })() - const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) - return await execute(sdk) - } - - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - const request = new Request(input, init) - return Server.Default().app.fetch(request) - }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) - await execute(sdk) + + const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + return Server.Default().app.fetch(request) + }) as typeof globalThis.fetch + const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) + await execute(sdk) }) }), }) From 0956b15c52fdf6741334e5f87109ac95e7870abf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:38:44 -0400 Subject: [PATCH 077/178] refactor(acp): drop async from synchronous ACP.init (#25520) --- packages/opencode/src/acp/agent.ts | 2 +- packages/opencode/src/cli/cmd/acp.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 8bbc2427fc..d66c1b2583 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -130,7 +130,7 @@ async function sendUsageUpdate( }) } -export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) { +export function init({ sdk: _sdk }: { sdk: OpencodeClient }) { return { create: (connection: AgentSideConnection, fullConfig: ACPConfig) => { return new Agent(connection, fullConfig) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 87671f5a00..251c608843 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -52,7 +52,7 @@ export const AcpCommand = effectCmd({ }) const stream = ndJsonStream(input, output) - const agent = yield* Effect.promise(() => ACP.init({ sdk })) + const agent = ACP.init({ sdk }) new AgentSideConnection((conn) => { return agent.create(conn, { sdk }) From 0ba013f8deb89d049fb6be645be652726741ceff Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 2 May 2026 21:43:48 -0500 Subject: [PATCH 078/178] chore: rm log statement (#25470) --- packages/opencode/src/permission/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 3fedd41d2c..d93670709e 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -144,7 +144,6 @@ interface State { } export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) return evalRule(permission, pattern, ...rulesets) } From b4cc7d13b65eb382f1a7a3d77aa5e370dc9a219b Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 3 May 2026 12:44:52 +1000 Subject: [PATCH 079/178] fix(desktop): limit zoom handler to zoom keys (#25516) --- .../src/renderer/webview-zoom.ts | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index 9c0a3a3a35..6e13266f45 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -26,13 +26,20 @@ const applyZoom = (next: number) => { window.addEventListener("keydown", (event) => { if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return - let newZoom = webviewZoom() - - if (event.key === "-") newZoom -= 0.2 - if (event.key === "=" || event.key === "+") newZoom += 0.2 - if (event.key === "0") newZoom = 1 - - applyZoom(clamp(newZoom)) + if (event.key === "-") { + event.preventDefault() + applyZoom(clamp(webviewZoom() - 0.2)) + return + } + if (event.key === "=" || event.key === "+") { + event.preventDefault() + applyZoom(clamp(webviewZoom() + 0.2)) + return + } + if (event.key === "0") { + event.preventDefault() + applyZoom(1) + } }) export { webviewZoom } From be88cd5cb9c86bdd8ad379de2b2dc5575798fa36 Mon Sep 17 00:00:00 2001 From: Youssef Achy <19510452+PanAchy@users.noreply.github.com> Date: Sat, 2 May 2026 21:52:32 -0500 Subject: [PATCH 080/178] chore(opencode): exclude .map files from CLI binary build (#25500) --- packages/opencode/script/build.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 35812f953d..2f2edb4ff5 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -61,6 +61,7 @@ const createEmbeddedWebUIBundle = async () => { await $`bun run --cwd ${appDir} build` const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist }))) .map((file) => file.replaceAll("\\", "/")) + .filter((file) => !file.endsWith(".map")) .sort() const imports = files.map((file, i) => { const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/") From af9fdf0a1c3f8da170658f6b8abf064bd1b30824 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 22:53:20 -0400 Subject: [PATCH 081/178] refactor(cli): convert github subcommands to effectCmd (#25522) --- packages/opencode/src/cli/cmd/github.ts | 41 +++++++++++++------------ 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index e707526dfe..f946e91ed4 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -18,10 +18,9 @@ import type { } from "@octokit/webhooks-types" import { UI } from "../ui" import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import { ModelsDev } from "@/provider/models" -import { Instance } from "@/project/instance" -import { WithInstance } from "@/project/with-instance" -import { bootstrap } from "../bootstrap" +import { InstanceRef } from "@/effect/instance-ref" import { SessionShare } from "@/share/session" import { Session } from "@/session/session" import type { SessionID } from "../../session/schema" @@ -200,13 +199,14 @@ export const GithubCommand = cmd({ async handler() {}, }) -export const GithubInstallCommand = cmd({ +export const GithubInstallCommand = effectCmd({ command: "install", describe: "install the GitHub agent", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { + handler: Effect.fn("Cli.github.install")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { { UI.empty() prompts.intro("Install GitHub agent") @@ -254,7 +254,7 @@ export const GithubInstallCommand = cmd({ } async function getAppInfo() { - const project = Instance.project + const project = ctx.project if (project.vcs !== "git") { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) throw new UI.CancelledError() @@ -262,14 +262,14 @@ export const GithubInstallCommand = cmd({ // Get repo info const info = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })), + Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })), ).then((x) => x.text().trim()) const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) throw new UI.CancelledError() } - return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree } + return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree } } async function promptProvider() { @@ -420,12 +420,11 @@ jobs: prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) } } - }, }) - }, + }), }) -export const GithubRunCommand = cmd({ +export const GithubRunCommand = effectCmd({ command: "run", describe: "run the GitHub agent", builder: (yargs) => @@ -438,8 +437,10 @@ export const GithubRunCommand = cmd({ type: "string", describe: "GitHub personal access token (github_pat_********)", }), - async handler(args) { - await bootstrap(process.cwd(), async () => { + handler: Effect.fn("Cli.github.run")(function* (args) { + const ctx = yield* InstanceRef + if (!ctx) return yield* Effect.die("InstanceRef not provided") + yield* Effect.promise(async () => { const isMock = args.token || args.event const context = isMock ? (JSON.parse(args.event!) as Context) : github.context @@ -502,21 +503,21 @@ export const GithubRunCommand = cmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } const gitStatus = (args: string[]) => - AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) + AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) @@ -1646,5 +1647,5 @@ query($owner: String!, $repo: String!, $number: Int!) { }) } }) - }, + }), }) From 31cb0bfa4fcf245a6a1baba45dc2f5b6336e8293 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 02:54:20 +0000 Subject: [PATCH 082/178] chore: generate --- packages/opencode/src/cli/cmd/github.ts | 316 ++++++++++++------------ 1 file changed, 158 insertions(+), 158 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index f946e91ed4..a4a209ea39 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -207,184 +207,184 @@ export const GithubInstallCommand = effectCmd({ if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx yield* Effect.promise(async () => { - { - UI.empty() - prompts.intro("Install GitHub agent") - const app = await getAppInfo() - await installGitHubApp() - - const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { - // TODO: add guide for copilot, for now just hide it - delete p["github-copilot"] - return p - }) - - const provider = await promptProvider() - const model = await promptModel() - //const key = await promptKey() + { + UI.empty() + prompts.intro("Install GitHub agent") + const app = await getAppInfo() + await installGitHubApp() + + const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { + // TODO: add guide for copilot, for now just hide it + delete p["github-copilot"] + return p + }) - await addWorkflowFiles() - printNextSteps() + const provider = await promptProvider() + const model = await promptModel() + //const key = await promptKey() - function printNextSteps() { - let step2 - if (provider === "amazon-bedrock") { - step2 = - "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" - } else { - step2 = [ - ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, - "", - ...providers[provider].env.map((e) => ` - ${e}`), - ].join("\n") - } + await addWorkflowFiles() + printNextSteps() - prompts.outro( - [ - "Next steps:", - "", - ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, - step2, - "", - " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", - "", - " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", - ].join("\n"), - ) + function printNextSteps() { + let step2 + if (provider === "amazon-bedrock") { + step2 = + "Configure OIDC in AWS - https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services" + } else { + step2 = [ + ` 2. Add the following secrets in org or repo (${app.owner}/${app.repo}) settings`, + "", + ...providers[provider].env.map((e) => ` - ${e}`), + ].join("\n") } - async function getAppInfo() { - const project = ctx.project - if (project.vcs !== "git") { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } + prompts.outro( + [ + "Next steps:", + "", + ` 1. Commit the \`${WORKFLOW_FILE}\` file and push`, + step2, + "", + " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", + "", + " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", + ].join("\n"), + ) + } - // Get repo info - const info = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })), - ).then((x) => x.text().trim()) - const parsed = parseGitHubRemote(info) - if (!parsed) { - prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) - throw new UI.CancelledError() - } - return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree } + async function getAppInfo() { + const project = ctx.project + if (project.vcs !== "git") { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() } - async function promptProvider() { - const priority: Record = { - opencode: 0, - anthropic: 1, - openai: 2, - google: 3, - } - let provider = await prompts.select({ - message: "Select provider", - maxItems: 8, - options: pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: priority[x.id] === 0 ? "recommended" : undefined, - })), - ), - }) - - if (prompts.isCancel(provider)) throw new UI.CancelledError() - - return provider + // Get repo info + const info = await AppRuntime.runPromise( + Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })), + ).then((x) => x.text().trim()) + const parsed = parseGitHubRemote(info) + if (!parsed) { + prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) + throw new UI.CancelledError() } + return { owner: parsed.owner, repo: parsed.repo, root: ctx.worktree } + } - async function promptModel() { - const providerData = providers[provider]! - - const model = await prompts.select({ - message: "Select model", - maxItems: 8, - options: pipe( - providerData.models, - values(), - sortBy((x) => x.name ?? x.id), - map((x) => ({ - label: x.name ?? x.id, - value: x.id, - })), + async function promptProvider() { + const priority: Record = { + opencode: 0, + anthropic: 1, + openai: 2, + google: 3, + } + let provider = await prompts.select({ + message: "Select provider", + maxItems: 8, + options: pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, ), - }) + map((x) => ({ + label: x.name, + value: x.id, + hint: priority[x.id] === 0 ? "recommended" : undefined, + })), + ), + }) - if (prompts.isCancel(model)) throw new UI.CancelledError() - return model - } + if (prompts.isCancel(provider)) throw new UI.CancelledError() - async function installGitHubApp() { - const s = prompts.spinner() - s.start("Installing GitHub app") + return provider + } - // Get installation - const installation = await getInstallation() - if (installation) return s.stop("GitHub app already installed") - - // Open browser - const url = "https://github.com/apps/opencode-agent" - const command = - process.platform === "darwin" - ? `open "${url}"` - : process.platform === "win32" - ? `start "" "${url}"` - : `xdg-open "${url}"` - - exec(command, (error) => { - if (error) { - prompts.log.warn(`Could not open browser. Please visit: ${url}`) - } - }) + async function promptModel() { + const providerData = providers[provider]! + + const model = await prompts.select({ + message: "Select model", + maxItems: 8, + options: pipe( + providerData.models, + values(), + sortBy((x) => x.name ?? x.id), + map((x) => ({ + label: x.name ?? x.id, + value: x.id, + })), + ), + }) - // Wait for installation - s.message("Waiting for GitHub app to be installed") - const MAX_RETRIES = 120 - let retries = 0 - do { - const installation = await getInstallation() - if (installation) break - - if (retries > MAX_RETRIES) { - s.stop( - `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, - ) - throw new UI.CancelledError() - } + if (prompts.isCancel(model)) throw new UI.CancelledError() + return model + } - retries++ - await sleep(1000) - } while (true) // oxlint-disable-line no-constant-condition + async function installGitHubApp() { + const s = prompts.spinner() + s.start("Installing GitHub app") + + // Get installation + const installation = await getInstallation() + if (installation) return s.stop("GitHub app already installed") + + // Open browser + const url = "https://github.com/apps/opencode-agent" + const command = + process.platform === "darwin" + ? `open "${url}"` + : process.platform === "win32" + ? `start "" "${url}"` + : `xdg-open "${url}"` + + exec(command, (error) => { + if (error) { + prompts.log.warn(`Could not open browser. Please visit: ${url}`) + } + }) - s.stop("Installed GitHub app") + // Wait for installation + s.message("Waiting for GitHub app to be installed") + const MAX_RETRIES = 120 + let retries = 0 + do { + const installation = await getInstallation() + if (installation) break - async function getInstallation() { - return await fetch( - `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + if (retries > MAX_RETRIES) { + s.stop( + `Failed to detect GitHub app installation. Make sure to install the app for the \`${app.owner}/${app.repo}\` repository.`, ) - .then((res) => res.json()) - .then((data) => data.installation) + throw new UI.CancelledError() } + + retries++ + await sleep(1000) + } while (true) // oxlint-disable-line no-constant-condition + + s.stop("Installed GitHub app") + + async function getInstallation() { + return await fetch( + `https://api.opencode.ai/get_github_app_installation?owner=${app.owner}&repo=${app.repo}`, + ) + .then((res) => res.json()) + .then((data) => data.installation) } + } - async function addWorkflowFiles() { - const envStr = - provider === "amazon-bedrock" - ? "" - : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` + async function addWorkflowFiles() { + const envStr = + provider === "amazon-bedrock" + ? "" + : `\n env:${providers[provider].env.map((e) => `\n ${e}: \${{ secrets.${e} }}`).join("")}` - await Filesystem.write( - path.join(app.root, WORKFLOW_FILE), - `name: opencode + await Filesystem.write( + path.join(app.root, WORKFLOW_FILE), + `name: opencode on: issue_comment: @@ -415,11 +415,11 @@ jobs: uses: anomalyco/opencode/github@latest${envStr} with: model: ${provider}/${model}`, - ) + ) - prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) - } + prompts.log.success(`Added workflow file: "${WORKFLOW_FILE}"`) } + } }) }), }) From db24f893137c545768adaf493da1bb541106cc6c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:03:32 -0400 Subject: [PATCH 083/178] refactor(cli): convert mcp list, auth, auth list, logout to effectCmd (#25521) --- packages/opencode/src/cli/cmd/mcp.ts | 501 +++++++++++++-------------- 1 file changed, 245 insertions(+), 256 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index e4d7bd9224..c220cbbdd8 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -1,4 +1,6 @@ import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" +import { Cause } from "effect" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" @@ -65,35 +67,31 @@ function oauthServers(config: Config.Info) { ) } -async function listState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const statuses = yield* mcp.status() - const stored = yield* Effect.all( - Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), - { concurrency: "unbounded" }, - ) - return { config, statuses, stored } - }), - ) +function listState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const statuses = yield* mcp.status() + const stored = yield* Effect.all( + Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])), + { concurrency: "unbounded" }, + ) + return { config, statuses, stored } + }) } -async function authState() { - return AppRuntime.runPromise( - Effect.gen(function* () { - const cfg = yield* Config.Service - const mcp = yield* MCP.Service - const config = yield* cfg.get() - const auth = yield* Effect.all( - Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), - { concurrency: "unbounded" }, - ) - return { config, auth } - }), - ) +function authState() { + return Effect.gen(function* () { + const cfg = yield* Config.Service + const mcp = yield* MCP.Service + const config = yield* cfg.get() + const auth = yield* Effect.all( + Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])), + { concurrency: "unbounded" }, + ) + return { config, auth } + }) } export const McpCommand = cmd({ @@ -110,73 +108,68 @@ export const McpCommand = cmd({ async handler() {}, }) -export const McpListCommand = cmd({ +export const McpListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list MCP servers and their status", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP Servers") - - const { config, statuses, stored } = await listState() - const servers = configuredServers(config) + handler: Effect.fn("Cli.mcp.list")(function* () { + UI.empty() + prompts.intro("MCP Servers") - if (servers.length === 0) { - prompts.log.warn("No MCP servers configured") - prompts.outro("Add servers with: opencode mcp add") - return - } + const { config, statuses, stored } = yield* listState() + const servers = configuredServers(config) - for (const [name, serverConfig] of servers) { - const status = statuses[name] - const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth - const hasStoredTokens = stored[name] - - let statusIcon: string - let statusText: string - let hint = "" - - if (!status) { - statusIcon = "○" - statusText = "not initialized" - } else if (status.status === "connected") { - statusIcon = "✓" - statusText = "connected" - if (hasOAuth && hasStoredTokens) { - hint = " (OAuth)" - } - } else if (status.status === "disabled") { - statusIcon = "○" - statusText = "disabled" - } else if (status.status === "needs_auth") { - statusIcon = "⚠" - statusText = "needs authentication" - } else if (status.status === "needs_client_registration") { - statusIcon = "✗" - statusText = "needs client registration" - hint = "\n " + status.error - } else { - statusIcon = "✗" - statusText = "failed" - hint = "\n " + status.error - } + if (servers.length === 0) { + prompts.log.warn("No MCP servers configured") + prompts.outro("Add servers with: opencode mcp add") + return + } - const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") - prompts.log.info( - `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, - ) + for (const [name, serverConfig] of servers) { + const status = statuses[name] + const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth + const hasStoredTokens = stored[name] + + let statusIcon: string + let statusText: string + let hint = "" + + if (!status) { + statusIcon = "○" + statusText = "not initialized" + } else if (status.status === "connected") { + statusIcon = "✓" + statusText = "connected" + if (hasOAuth && hasStoredTokens) { + hint = " (OAuth)" } + } else if (status.status === "disabled") { + statusIcon = "○" + statusText = "disabled" + } else if (status.status === "needs_auth") { + statusIcon = "⚠" + statusText = "needs authentication" + } else if (status.status === "needs_client_registration") { + statusIcon = "✗" + statusText = "needs client registration" + hint = "\n " + status.error + } else { + statusIcon = "✗" + statusText = "failed" + hint = "\n " + status.error + } - prompts.outro(`${servers.length} server(s)`) - }, - }) - }, + const typeHint = serverConfig.type === "remote" ? serverConfig.url : serverConfig.command.join(" ") + prompts.log.info( + `${statusIcon} ${name} ${UI.Style.TEXT_DIM}${statusText}${hint}\n ${UI.Style.TEXT_DIM}${typeHint}`, + ) + } + + prompts.outro(`${servers.length} server(s)`) + }), }) -export const McpAuthCommand = cmd({ +export const McpAuthCommand = effectCmd({ command: "auth [name]", describe: "authenticate with an OAuth-enabled MCP server", builder: (yargs) => @@ -186,105 +179,106 @@ export const McpAuthCommand = cmd({ type: "string", }) .command(McpAuthListCommand), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Authentication") - - const { config, auth } = await authState() - const mcpServers = config.mcp ?? {} - const servers = oauthServers(config) - - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") - prompts.log.info(` + handler: Effect.fn("Cli.mcp.auth")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Authentication") + + const { config, auth } = yield* authState() + const mcpServers = config.mcp ?? {} + const servers = oauthServers(config) + + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") + prompts.log.info(` "mcp": { "my-server": { "type": "remote", "url": "https://example.com/mcp" } }`) - prompts.outro("Done") - return - } - - let serverName = args.name - if (!serverName) { - // Build options with auth status - const options = servers.map(([name, cfg]) => { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = cfg.url - return { - label: `${icon} ${name} (${statusText})`, - value: name, - hint: url, - } - }) + prompts.outro("Done") + return + } - const selected = await prompts.select({ - message: "Select MCP server to authenticate", - options, - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected + let serverName = args.name + if (!serverName) { + // Build options with auth status + const options = servers.map(([name, cfg]) => { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = cfg.url + return { + label: `${icon} ${name} (${statusText})`, + value: name, + hint: url, } + }) - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return - } + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to authenticate", + options, + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } - if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { - prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) - prompts.outro("Done") - return - } + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } - // Check if already authenticated - const authStatus = - auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName)))) - if (authStatus === "authenticated") { - const confirm = await prompts.confirm({ - message: `${serverName} already has valid credentials. Re-authenticate?`, - }) - if (prompts.isCancel(confirm) || !confirm) { - prompts.outro("Cancelled") - return - } - } else if (authStatus === "expired") { - prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) - } + if (!isMcpRemote(serverConfig) || serverConfig.oauth === false) { + prompts.log.error(`MCP server ${serverName} is not an OAuth-capable remote server`) + prompts.outro("Done") + return + } - const spinner = prompts.spinner() - spinner.start("Starting OAuth flow...") - - // Subscribe to browser open failure events to show URL for manual opening - const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { - if (evt.properties.mcpName === serverName) { - spinner.stop("Could not open browser automatically") - prompts.log.warn("Please open this URL in your browser to authenticate:") - prompts.log.info(evt.properties.url) - spinner.start("Waiting for authorization...") - } - }) + // Check if already authenticated + const authStatus = auth[serverName] ?? (yield* MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))) + if (authStatus === "authenticated") { + const confirm = yield* Effect.promise(() => + prompts.confirm({ + message: `${serverName} already has valid credentials. Re-authenticate?`, + }), + ) + if (prompts.isCancel(confirm) || !confirm) { + prompts.outro("Cancelled") + return + } + } else if (authStatus === "expired") { + prompts.log.warn(`${serverName} has expired credentials. Re-authenticating...`) + } - try { - const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName))) + const spinner = prompts.spinner() + spinner.start("Starting OAuth flow...") - if (status.status === "connected") { - spinner.stop("Authentication successful!") - } else if (status.status === "needs_client_registration") { - spinner.stop("Authentication failed", 1) - prompts.log.error(status.error) - prompts.log.info("Add clientId to your MCP server config:") - prompts.log.info(` + // Subscribe to browser open failure events to show URL for manual opening + const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => { + if (evt.properties.mcpName === serverName) { + spinner.stop("Could not open browser automatically") + prompts.log.warn("Please open this URL in your browser to authenticate:") + prompts.log.info(evt.properties.url) + spinner.start("Waiting for authorization...") + } + }) + + yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)) + .pipe( + Effect.tap((status) => + Effect.sync(() => { + if (status.status === "connected") { + spinner.stop("Authentication successful!") + } else if (status.status === "needs_client_registration") { + spinner.stop("Authentication failed", 1) + prompts.log.error(status.error) + prompts.log.info("Add clientId to your MCP server config:") + prompts.log.info(` "mcp": { "${serverName}": { "type": "remote", @@ -295,61 +289,59 @@ export const McpAuthCommand = cmd({ } } }`) - } else if (status.status === "failed") { + } else if (status.status === "failed") { + spinner.stop("Authentication failed", 1) + prompts.log.error(status.error) + } else { + spinner.stop("Unexpected status: " + status.status, 1) + } + }), + ), + Effect.catchCause((cause) => + Effect.sync(() => { spinner.stop("Authentication failed", 1) - prompts.log.error(status.error) - } else { - spinner.stop("Unexpected status: " + status.status, 1) - } - } catch (error) { - spinner.stop("Authentication failed", 1) - prompts.log.error(error instanceof Error ? error.message : String(error)) - } finally { - unsubscribe() - } + const error = Cause.squash(cause) + prompts.log.error(error instanceof Error ? error.message : String(error)) + }), + ), + Effect.ensuring(Effect.sync(() => unsubscribe())), + ) - prompts.outro("Done") - }, - }) - }, + prompts.outro("Done") + }), }) -export const McpAuthListCommand = cmd({ +export const McpAuthListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list OAuth-capable MCP servers and their auth status", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Status") + handler: Effect.fn("Cli.mcp.auth.list")(function* () { + UI.empty() + prompts.intro("MCP OAuth Status") - const { config, auth } = await authState() - const servers = oauthServers(config) + const { config, auth } = yield* authState() + const servers = oauthServers(config) - if (servers.length === 0) { - prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.outro("Done") - return - } + if (servers.length === 0) { + prompts.log.warn("No OAuth-capable MCP servers configured") + prompts.outro("Done") + return + } - for (const [name, serverConfig] of servers) { - const authStatus = auth[name] - const icon = getAuthStatusIcon(authStatus) - const statusText = getAuthStatusText(authStatus) - const url = serverConfig.url + for (const [name, serverConfig] of servers) { + const authStatus = auth[name] + const icon = getAuthStatusIcon(authStatus) + const statusText = getAuthStatusText(authStatus) + const url = serverConfig.url - prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) - } + prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`) + } - prompts.outro(`${servers.length} OAuth-capable server(s)`) - }, - }) - }, + prompts.outro(`${servers.length} OAuth-capable server(s)`) + }), }) -export const McpLogoutCommand = cmd({ +export const McpLogoutCommand = effectCmd({ command: "logout [name]", describe: "remove OAuth credentials for an MCP server", builder: (yargs) => @@ -357,57 +349,54 @@ export const McpLogoutCommand = cmd({ describe: "name of the MCP server", type: "string", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { - UI.empty() - prompts.intro("MCP OAuth Logout") + handler: Effect.fn("Cli.mcp.logout")(function* (args) { + UI.empty() + prompts.intro("MCP OAuth Logout") - const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all())) - const serverNames = Object.keys(credentials) + const credentials = yield* McpAuth.Service.use((auth) => auth.all()) + const serverNames = Object.keys(credentials) - if (serverNames.length === 0) { - prompts.log.warn("No MCP OAuth credentials stored") - prompts.outro("Done") - return - } + if (serverNames.length === 0) { + prompts.log.warn("No MCP OAuth credentials stored") + prompts.outro("Done") + return + } - let serverName = args.name - if (!serverName) { - const selected = await prompts.select({ - message: "Select MCP server to logout", - options: serverNames.map((name) => { - const entry = credentials[name] - const hasTokens = !!entry.tokens - const hasClient = !!entry.clientInfo - let hint = "" - if (hasTokens && hasClient) hint = "tokens + client" - else if (hasTokens) hint = "tokens" - else if (hasClient) hint = "client registration" - return { - label: name, - value: name, - hint, - } - }), - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - serverName = selected - } + let serverName = args.name + if (!serverName) { + const selected = yield* Effect.promise(() => + prompts.select({ + message: "Select MCP server to logout", + options: serverNames.map((name) => { + const entry = credentials[name] + const hasTokens = !!entry.tokens + const hasClient = !!entry.clientInfo + let hint = "" + if (hasTokens && hasClient) hint = "tokens + client" + else if (hasTokens) hint = "tokens" + else if (hasClient) hint = "client registration" + return { + label: name, + value: name, + hint, + } + }), + }), + ) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + serverName = selected + } - if (!credentials[serverName]) { - prompts.log.error(`No credentials found for: ${serverName}`) - prompts.outro("Done") - return - } + if (!credentials[serverName]) { + prompts.log.error(`No credentials found for: ${serverName}`) + prompts.outro("Done") + return + } - await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName))) - prompts.log.success(`Removed OAuth credentials for ${serverName}`) - prompts.outro("Done") - }, - }) - }, + yield* MCP.Service.use((mcp) => mcp.removeAuth(serverName)) + prompts.log.success(`Removed OAuth credentials for ${serverName}`) + prompts.outro("Done") + }), }) async function resolveConfigPath(baseDir: string, global = false) { From a3d282a4c2a4ae9a7fb21d9802f82979458dac4e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 03:04:40 +0000 Subject: [PATCH 084/178] chore: generate --- packages/opencode/src/cli/cmd/mcp.ts | 53 ++++++++++++++-------------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c220cbbdd8..a2a956c3b6 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -268,17 +268,16 @@ export const McpAuthCommand = effectCmd({ } }) - yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)) - .pipe( - Effect.tap((status) => - Effect.sync(() => { - if (status.status === "connected") { - spinner.stop("Authentication successful!") - } else if (status.status === "needs_client_registration") { - spinner.stop("Authentication failed", 1) - prompts.log.error(status.error) - prompts.log.info("Add clientId to your MCP server config:") - prompts.log.info(` + yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe( + Effect.tap((status) => + Effect.sync(() => { + if (status.status === "connected") { + spinner.stop("Authentication successful!") + } else if (status.status === "needs_client_registration") { + spinner.stop("Authentication failed", 1) + prompts.log.error(status.error) + prompts.log.info("Add clientId to your MCP server config:") + prompts.log.info(` "mcp": { "${serverName}": { "type": "remote", @@ -289,23 +288,23 @@ export const McpAuthCommand = effectCmd({ } } }`) - } else if (status.status === "failed") { - spinner.stop("Authentication failed", 1) - prompts.log.error(status.error) - } else { - spinner.stop("Unexpected status: " + status.status, 1) - } - }), - ), - Effect.catchCause((cause) => - Effect.sync(() => { + } else if (status.status === "failed") { spinner.stop("Authentication failed", 1) - const error = Cause.squash(cause) - prompts.log.error(error instanceof Error ? error.message : String(error)) - }), - ), - Effect.ensuring(Effect.sync(() => unsubscribe())), - ) + prompts.log.error(status.error) + } else { + spinner.stop("Unexpected status: " + status.status, 1) + } + }), + ), + Effect.catchCause((cause) => + Effect.sync(() => { + spinner.stop("Authentication failed", 1) + const error = Cause.squash(cause) + prompts.log.error(error instanceof Error ? error.message : String(error)) + }), + ), + Effect.ensuring(Effect.sync(() => unsubscribe())), + ) prompts.outro("Done") }), From a79a6594b064429b2f13c92f9b85291b051ca750 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:08:13 -0400 Subject: [PATCH 085/178] chore: bump Effect beta (#25524) --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 12677ea976..25068f3d9a 100644 --- a/bun.lock +++ b/bun.lock @@ -715,7 +715,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -3078,7 +3078,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="], + "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], diff --git a/package.json b/package.json index b15fbb2544..de3dd31f40 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", - "effect": "4.0.0-beta.57", + "effect": "4.0.0-beta.59", "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.10.7", From bdabb102fe5e04a6bbbc10114fb2f22cef6dc6ea Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:08:26 -0400 Subject: [PATCH 086/178] =?UTF-8?q?refactor(cli/stats):=20Stage=204=20?= =?UTF-8?q?=E2=80=94=20fully=20Effect-native=20body=20(#25523)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/stats.ts | 234 +++++++++++-------------- 1 file changed, 106 insertions(+), 128 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 8bf7b2345c..0124a26932 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -5,7 +5,6 @@ import { Database } from "@/storage/db" import { SessionTable } from "../../session/session.sql" import { Project } from "@/project/project" import { InstanceRef } from "@/effect/instance-ref" -import { AppRuntime } from "@/effect/app-runtime" interface SessionStats { totalSessions: number @@ -69,38 +68,28 @@ export const StatsCommand = effectCmd({ handler: Effect.fn("Cli.stats")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return - return yield* run(args, ctx.project) - }), -}) - -const run = ( - args: { days?: number; tools?: number; models?: unknown; project?: string }, - currentProject: Project.Info, -) => - Effect.promise(async () => { - const stats = await aggregateSessionStats(args.days, args.project, currentProject) - + const stats = yield* aggregateSessionStats(args.days, args.project, ctx.project) let modelLimit: number | undefined if (args.models === true) { modelLimit = Infinity } else if (typeof args.models === "number") { modelLimit = args.models } - displayStats(stats, args.tools, modelLimit) - }) + }), +}) -async function getAllSessions(): Promise { - const rows = Database.use((db) => db.select().from(SessionTable).all()) - return rows.map((row) => Session.fromRow(row)) -} +const getAllSessions = Effect.sync(() => + Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)), +) -export async function aggregateSessionStats( +const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* ( days?: number, projectFilter?: string, currentProject?: Project.Info, -): Promise { - const sessions = await getAllSessions() +) { + const svc = yield* Session.Service + const sessions = yield* getAllSessions const MS_IN_DAY = 24 * 60 * 60 * 1000 const cutoffTime = (() => { @@ -169,122 +158,111 @@ export async function aggregateSessionStats( const sessionTotalTokens: number[] = [] - const BATCH_SIZE = 20 - for (let i = 0; i < filteredSessions.length; i += BATCH_SIZE) { - const batch = filteredSessions.slice(i, i + BATCH_SIZE) - - const batchPromises = batch.map(async (session) => { - const messages = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID: session.id })), - ) - - let sessionCost = 0 - let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } - let sessionToolUsage: Record = {} - let sessionModelUsage: Record< - string, - { - messages: number - tokens: { - input: number - output: number - cache: { - read: number - write: number - } + const results = yield* Effect.forEach( + filteredSessions, + (session) => + Effect.gen(function* () { + const messages = yield* svc.messages({ sessionID: session.id }) + + let sessionCost = 0 + let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + let sessionToolUsage: Record = {} + let sessionModelUsage: Record< + string, + { + messages: number + tokens: { input: number; output: number; cache: { read: number; write: number } } + cost: number } - cost: number - } - > = {} - - for (const message of messages) { - if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 - - const modelKey = `${message.info.providerID}/${message.info.modelID}` - if (!sessionModelUsage[modelKey]) { - sessionModelUsage[modelKey] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, + > = {} + + for (const message of messages) { + if (message.info.role === "assistant") { + sessionCost += message.info.cost || 0 + + const modelKey = `${message.info.providerID}/${message.info.modelID}` + if (!sessionModelUsage[modelKey]) { + sessionModelUsage[modelKey] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, + } + } + sessionModelUsage[modelKey].messages++ + sessionModelUsage[modelKey].cost += message.info.cost || 0 + + if (message.info.tokens) { + sessionTokens.input += message.info.tokens.input || 0 + sessionTokens.output += message.info.tokens.output || 0 + sessionTokens.reasoning += message.info.tokens.reasoning || 0 + sessionTokens.cache.read += message.info.tokens.cache?.read || 0 + sessionTokens.cache.write += message.info.tokens.cache?.write || 0 + + sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 + sessionModelUsage[modelKey].tokens.output += + (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) + sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 + sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 } } - sessionModelUsage[modelKey].messages++ - sessionModelUsage[modelKey].cost += message.info.cost || 0 - - if (message.info.tokens) { - sessionTokens.input += message.info.tokens.input || 0 - sessionTokens.output += message.info.tokens.output || 0 - sessionTokens.reasoning += message.info.tokens.reasoning || 0 - sessionTokens.cache.read += message.info.tokens.cache?.read || 0 - sessionTokens.cache.write += message.info.tokens.cache?.write || 0 - - sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0 - sessionModelUsage[modelKey].tokens.output += - (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0) - sessionModelUsage[modelKey].tokens.cache.read += message.info.tokens.cache?.read || 0 - sessionModelUsage[modelKey].tokens.cache.write += message.info.tokens.cache?.write || 0 - } - } - for (const part of message.parts) { - if (part.type === "tool" && part.tool) { - sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 + for (const part of message.parts) { + if (part.type === "tool" && part.tool) { + sessionToolUsage[part.tool] = (sessionToolUsage[part.tool] || 0) + 1 + } } } - } - - return { - messageCount: messages.length, - sessionCost, - sessionTokens, - sessionTotalTokens: - sessionTokens.input + - sessionTokens.output + - sessionTokens.reasoning + - sessionTokens.cache.read + - sessionTokens.cache.write, - sessionToolUsage, - sessionModelUsage, - earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, - latestTime: session.time.updated, - } - }) - - const batchResults = await Promise.all(batchPromises) - for (const result of batchResults) { - earliestTime = Math.min(earliestTime, result.earliestTime) - latestTime = Math.max(latestTime, result.latestTime) - sessionTotalTokens.push(result.sessionTotalTokens) - - stats.totalMessages += result.messageCount - stats.totalCost += result.sessionCost - stats.totalTokens.input += result.sessionTokens.input - stats.totalTokens.output += result.sessionTokens.output - stats.totalTokens.reasoning += result.sessionTokens.reasoning - stats.totalTokens.cache.read += result.sessionTokens.cache.read - stats.totalTokens.cache.write += result.sessionTokens.cache.write - - for (const [tool, count] of Object.entries(result.sessionToolUsage)) { - stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count - } + return { + messageCount: messages.length, + sessionCost, + sessionTokens, + sessionTotalTokens: + sessionTokens.input + + sessionTokens.output + + sessionTokens.reasoning + + sessionTokens.cache.read + + sessionTokens.cache.write, + sessionToolUsage, + sessionModelUsage, + earliestTime: cutoffTime > 0 ? session.time.updated : session.time.created, + latestTime: session.time.updated, + } + }), + { concurrency: 20 }, + ) + + for (const result of results) { + earliestTime = Math.min(earliestTime, result.earliestTime) + latestTime = Math.max(latestTime, result.latestTime) + sessionTotalTokens.push(result.sessionTotalTokens) + + stats.totalMessages += result.messageCount + stats.totalCost += result.sessionCost + stats.totalTokens.input += result.sessionTokens.input + stats.totalTokens.output += result.sessionTokens.output + stats.totalTokens.reasoning += result.sessionTokens.reasoning + stats.totalTokens.cache.read += result.sessionTokens.cache.read + stats.totalTokens.cache.write += result.sessionTokens.cache.write + + for (const [tool, count] of Object.entries(result.sessionToolUsage)) { + stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count + } - for (const [model, usage] of Object.entries(result.sessionModelUsage)) { - if (!stats.modelUsage[model]) { - stats.modelUsage[model] = { - messages: 0, - tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, - cost: 0, - } + for (const [model, usage] of Object.entries(result.sessionModelUsage)) { + if (!stats.modelUsage[model]) { + stats.modelUsage[model] = { + messages: 0, + tokens: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + cost: 0, } - stats.modelUsage[model].messages += usage.messages - stats.modelUsage[model].tokens.input += usage.tokens.input - stats.modelUsage[model].tokens.output += usage.tokens.output - stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read - stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write - stats.modelUsage[model].cost += usage.cost } + stats.modelUsage[model].messages += usage.messages + stats.modelUsage[model].tokens.input += usage.tokens.input + stats.modelUsage[model].tokens.output += usage.tokens.output + stats.modelUsage[model].tokens.cache.read += usage.tokens.cache.read + stats.modelUsage[model].tokens.cache.write += usage.tokens.cache.write + stats.modelUsage[model].cost += usage.cost } } @@ -313,7 +291,7 @@ export async function aggregateSessionStats( : sessionTotalTokens[mid] return stats -} +}) export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) { const width = 56 From 5f03d892c099c19b54f72d3dabc9c35d362162d6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:19:33 -0400 Subject: [PATCH 087/178] fix(httpapi): pagination Link header echoes request host (#25527) --- .../instance/httpapi/handlers/session.ts | 6 +- .../test/server/httpapi-parity.test.ts | 128 ++++++++++++++++++ 2 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/server/httpapi-parity.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 8cc969f483..4a67ba036e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -18,7 +18,7 @@ import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { NotFoundError } from "@/storage/storage" import { NamedError } from "@opencode-ai/core/util/error" -import { Cause, Effect, Schema, Scope } from "effect" +import { Cause, Effect, Option, Schema, Scope } from "effect" import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" @@ -125,7 +125,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", if (!page.cursor) return page.items const request = yield* HttpServerRequest.HttpServerRequest - const url = new URL(request.url, "http://localhost") + // toURL() honors the Host + x-forwarded-proto headers, so the Link + // header echoes the real origin instead of a hard-coded localhost. + const url = Option.getOrElse(HttpServerRequest.toURL(request), () => new URL(request.url, "http://localhost")) url.searchParams.set("limit", ctx.query.limit.toString()) url.searchParams.set("before", page.cursor) return HttpServerResponse.jsonUnsafe(page.items, { diff --git a/packages/opencode/test/server/httpapi-parity.test.ts b/packages/opencode/test/server/httpapi-parity.test.ts new file mode 100644 index 0000000000..6922d8c43f --- /dev/null +++ b/packages/opencode/test/server/httpapi-parity.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { WithInstance } from "../../src/project/with-instance" +import { Server } from "../../src/server/server" +import { Session } from "@/session/session" +import { MessageID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +function app(experimental: boolean) { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental + return experimental ? Server.Default().app : Server.Legacy().app +} + +function runSession(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function createSessionWithMessages(directory: string, count: number) { + return WithInstance.provide({ + directory, + fn: async () => { + const session = await runSession(Session.Service.use((svc) => svc.create({}))) + for (let i = 0; i < count; i++) { + await runSession( + Effect.gen(function* () { + const svc = yield* Session.Service + yield* svc.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: session.id, + agent: "build", + model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, + time: { created: Date.now() }, + }) + }), + ) + } + return session.id + }, + }) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 1: Link header should reflect the request's actual Host header, +// not "localhost". HttpApi uses `new URL(request.url, "http://localhost")` +// which embeds localhost because request.url is path-only. Fix: use +// `HttpServerRequest.toURL(request)` which honors the Host header. +// ────────────────────────────────────────────────────────────────────────────── +describe("Link header host", () => { + test("HttpApi pagination Link header echoes request host", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + const sessionID = await createSessionWithMessages(tmp.path, 3) + + const response = await app(true).request(`/session/${sessionID}/message?limit=2`, { + headers: { + host: "opencode.test:4096", + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + const link = response.headers.get("link") + expect(link).not.toBeNull() + // Link should contain the request's Host, not "localhost". + expect(link).toContain("opencode.test") + expect(link).not.toContain("localhost") + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 2: GET /session/{missing-id}/todo should return 404, not 500. +// The session.todo handler in HttpApi doesn't wrap with `mapNotFound`, so a +// `NotFoundError` from the service surfaces as a defect → 500. Hono's +// equivalent maps to 404 via `errors.notFound`. +// +// Affected endpoints (handlers without mapNotFound): todo, diff, summarize, +// fork, abort, init, deleteMessage, command, shell, revert, unrevert. +// +// FIXME: unskip when mapNotFound coverage is added (next PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("404 mapping for missing session", () => { + test.todo("HttpApi /session/{missing}/todo returns 404 not 500", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist/todo", { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + }) +}) + +// ────────────────────────────────────────────────────────────────────────────── +// Reproducer 3: 404 response body shape should match Hono's NamedError +// envelope `{ name, data: { message } }`. HttpApi returns the typed-error +// shape `{ _tag }` instead. SDK consumers reading `error.data.message` +// see undefined. +// +// FIXME: unskip when error JSON shape policy is decided + applied (separate PR). +// ────────────────────────────────────────────────────────────────────────────── +describe("Error JSON shape parity", () => { + test.todo("HttpApi 404 body matches NamedError shape", async () => { + await using tmp = await tmpdir({ config: { formatter: false, lsp: false } }) + + const response = await app(true).request("/session/ses_does_not_exist", { + headers: { "x-opencode-directory": tmp.path }, + }) + + expect(response.status).toBe(404) + const body = (await response.json()) as { name?: string; data?: { message?: string } } + expect(body.name).toBe("NotFoundError") + expect(typeof body.data?.message).toBe("string") + }) +}) From 0e13279545f443f0186aee59e868ca9f781e875b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:22:44 -0400 Subject: [PATCH 088/178] refactor(cli): convert agent / providers / mcp to effectCmd (#25525) --- packages/opencode/src/cli/cmd/agent.ts | 23 ++++++++------- packages/opencode/src/cli/cmd/mcp.ts | 32 +++++++++------------ packages/opencode/src/cli/cmd/providers.ts | 33 +++++++++++++--------- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 4011269495..e2565c6272 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -9,8 +9,7 @@ import path from "path" import fs from "fs/promises" import { Filesystem } from "@/util/filesystem" import matter from "gray-matter" -import { Instance } from "../../project/instance" -import { WithInstance } from "../../project/with-instance" +import { InstanceRef } from "@/effect/instance-ref" import { EOL } from "os" import type { Argv } from "yargs" import { Effect } from "effect" @@ -35,7 +34,7 @@ const AVAILABLE_PERMISSIONS = [ "skill", ] -const AgentCreateCommand = cmd({ +const AgentCreateCommand = effectCmd({ command: "create", describe: "create a new agent", builder: (yargs: Argv) => @@ -63,10 +62,11 @@ const AgentCreateCommand = cmd({ alias: ["m"], describe: "model to use in the format of provider/model", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { + handler: Effect.fn("Cli.agent.create")(function* (args) { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { const cliPath = args.path const cliDescription = args.description const cliMode = args.mode as AgentMode | undefined @@ -79,7 +79,7 @@ const AgentCreateCommand = cmd({ prompts.intro("Create agent") } - const project = Instance.project + const project = ctx.project // Determine scope/path let targetPath: string @@ -94,7 +94,7 @@ const AgentCreateCommand = cmd({ { label: "Current project", value: "project" as const, - hint: Instance.worktree, + hint: ctx.worktree, }, { label: "Global", @@ -107,7 +107,7 @@ const AgentCreateCommand = cmd({ scope = scopeResult } targetPath = path.join( - scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"), + scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent", ) } @@ -230,9 +230,8 @@ const AgentCreateCommand = cmd({ prompts.log.success(`Agent created: ${filePath}`) prompts.outro("Done") } - }, }) - }, + }), }) const AgentListCommand = effectCmd({ diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index a2a956c3b6..d1e8b33be7 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -11,8 +11,7 @@ import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" import { ConfigMCP } from "../../config/mcp" -import { Instance } from "../../project/instance" -import { WithInstance } from "../../project/with-instance" +import { InstanceRef } from "@/effect/instance-ref" import { Installation } from "../../installation" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" @@ -433,21 +432,22 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPat return configPath } -export const McpAddCommand = cmd({ +export const McpAddCommand = effectCmd({ command: "add", describe: "add an MCP server", - async handler() { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { + handler: Effect.fn("Cli.mcp.add")(function* () { + const maybeCtx = yield* InstanceRef + if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") + const ctx = maybeCtx + yield* Effect.promise(async () => { UI.empty() prompts.intro("Add MCP server") - const project = Instance.project + const project = ctx.project // Resolve config paths eagerly for hints const [projectConfigPath, globalConfigPath] = await Promise.all([ - resolveConfigPath(Instance.worktree), + resolveConfigPath(ctx.worktree), resolveConfigPath(Global.Path.config, true), ]) @@ -592,12 +592,11 @@ export const McpAddCommand = cmd({ } prompts.outro("MCP server added successfully") - }, }) - }, + }), }) -export const McpDebugCommand = cmd({ +export const McpDebugCommand = effectCmd({ command: "debug ", describe: "debug OAuth connection for an MCP server", builder: (yargs) => @@ -606,10 +605,8 @@ export const McpDebugCommand = cmd({ type: "string", demandOption: true, }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { + handler: Effect.fn("Cli.mcp.debug")(function* (args) { + yield* Effect.promise(async () => { UI.empty() prompts.intro("MCP OAuth Debug") @@ -781,7 +778,6 @@ export const McpDebugCommand = cmd({ } prompts.outro("Debug complete") - }, }) - }, + }), }) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index ca64526182..93541114b4 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,6 +1,7 @@ import { Auth } from "../../auth" import { AppRuntime } from "../../effect/app-runtime" import { cmd } from "./cmd" +import { effectCmd } from "../effect-cmd" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { ModelsDev } from "@/provider/models" @@ -13,7 +14,6 @@ import os from "os" import { Config } from "@/config/config" import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" -import { WithInstance } from "../../project/with-instance" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" import { text } from "node:stream/consumers" @@ -232,11 +232,14 @@ export const ProvidersCommand = cmd({ async handler() {}, }) -export const ProvidersListCommand = cmd({ +export const ProvidersListCommand = effectCmd({ command: "list", aliases: ["ls"], describe: "list providers and credentials", - async handler(_args) { + // Lists global credentials + provider env vars; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.list")(function* (_args) { + yield* Effect.promise(async () => { UI.empty() const authPath = path.join(Global.Path.data, "auth.json") const homedir = os.homedir() @@ -280,10 +283,11 @@ export const ProvidersListCommand = cmd({ prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) } - }, + }) + }), }) -export const ProvidersLoginCommand = cmd({ +export const ProvidersLoginCommand = effectCmd({ command: "login [url]", describe: "log in to a provider", builder: (yargs) => @@ -302,10 +306,8 @@ export const ProvidersLoginCommand = cmd({ describe: "login method label (skips method selection)", type: "string", }), - async handler(args) { - await WithInstance.provide({ - directory: process.cwd(), - async fn() { + handler: Effect.fn("Cli.providers.login")(function* (args) { + yield* Effect.promise(async () => { UI.empty() prompts.intro("Add credential") if (args.url) { @@ -487,15 +489,17 @@ export const ProvidersLoginCommand = cmd({ }) prompts.outro("Done") - }, }) - }, + }), }) -export const ProvidersLogoutCommand = cmd({ +export const ProvidersLogoutCommand = effectCmd({ command: "logout", describe: "log out from a configured provider", - async handler(_args) { + // Removes a global auth credential; no project instance needed. + instance: false, + handler: Effect.fn("Cli.providers.logout")(function* (_args) { + yield* Effect.promise(async () => { UI.empty() const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( Effect.gen(function* () { @@ -525,5 +529,6 @@ export const ProvidersLogoutCommand = cmd({ }), ) prompts.outro("Logout successful") - }, + }) + }), }) From 3f1ce36418835423b79cf4a50f9086a538c37f12 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 03:23:47 +0000 Subject: [PATCH 089/178] chore: generate --- packages/opencode/src/cli/cmd/agent.ts | 279 ++++++----- packages/opencode/src/cli/cmd/mcp.ts | 540 ++++++++++----------- packages/opencode/src/cli/cmd/providers.ts | 442 ++++++++--------- 3 files changed, 629 insertions(+), 632 deletions(-) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index e2565c6272..a5bcd7873b 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -67,169 +67,166 @@ const AgentCreateCommand = effectCmd({ if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx yield* Effect.promise(async () => { - const cliPath = args.path - const cliDescription = args.description - const cliMode = args.mode as AgentMode | undefined - const perms = args.permissions + const cliPath = args.path + const cliDescription = args.description + const cliMode = args.mode as AgentMode | undefined + const perms = args.permissions - const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined + const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined - if (!isFullyNonInteractive) { - UI.empty() - prompts.intro("Create agent") - } - - const project = ctx.project - - // Determine scope/path - let targetPath: string - if (cliPath) { - targetPath = path.join(cliPath, "agent") - } else { - let scope: "global" | "project" = "global" - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: "project" as const, - hint: ctx.worktree, - }, - { - label: "Global", - value: "global" as const, - hint: Global.Path.config, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - scope = scopeResult - } - targetPath = path.join( - scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), - "agent", - ) - } - - // Get description - let description: string - if (cliDescription) { - description = cliDescription - } else { - const query = await prompts.text({ - message: "Description", - placeholder: "What should this agent do?", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(query)) throw new UI.CancelledError() - description = query - } - - // Generate agent - const spinner = prompts.spinner() - spinner.start("Generating agent configuration...") - const model = args.model ? Provider.parseModel(args.model) : undefined - const generated = await AppRuntime.runPromise( - Agent.Service.use((svc) => svc.generate({ description, model })), - ).catch((error) => { - spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) - if (isFullyNonInteractive) process.exit(1) - throw new UI.CancelledError() - }) - spinner.stop(`Agent ${generated.identifier} generated`) + if (!isFullyNonInteractive) { + UI.empty() + prompts.intro("Create agent") + } - // Select permissions to allow - let selected: string[] - if (perms !== undefined) { - selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS - } else { - const result = await prompts.multiselect({ - message: "Select permissions to allow (Space to toggle)", - options: AVAILABLE_PERMISSIONS.map((permission) => ({ - label: permission, - value: permission, - })), - initialValues: AVAILABLE_PERMISSIONS, - }) - if (prompts.isCancel(result)) throw new UI.CancelledError() - selected = result - } + const project = ctx.project - // Get mode - let mode: AgentMode - if (cliMode) { - mode = cliMode - } else { - const modeResult = await prompts.select({ - message: "Agent mode", + // Determine scope/path + let targetPath: string + if (cliPath) { + targetPath = path.join(cliPath, "agent") + } else { + let scope: "global" | "project" = "global" + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "All", - value: "all" as const, - hint: "Can function in both primary and subagent roles", - }, - { - label: "Primary", - value: "primary" as const, - hint: "Acts as a primary/main agent", + label: "Current project", + value: "project" as const, + hint: ctx.worktree, }, { - label: "Subagent", - value: "subagent" as const, - hint: "Can be used as a subagent by other agents", + label: "Global", + value: "global" as const, + hint: Global.Path.config, }, ], - initialValue: "all" as const, }) - if (prompts.isCancel(modeResult)) throw new UI.CancelledError() - mode = modeResult + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + scope = scopeResult } + targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent") + } - // Build permissions config — deny anything not explicitly selected. - const permissions: Record = {} - for (const permission of AVAILABLE_PERMISSIONS) { - if (!selected.includes(permission)) { - permissions[permission] = "deny" - } - } + // Get description + let description: string + if (cliDescription) { + description = cliDescription + } else { + const query = await prompts.text({ + message: "Description", + placeholder: "What should this agent do?", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(query)) throw new UI.CancelledError() + description = query + } - // Build frontmatter - const frontmatter: { - description: string - mode: AgentMode - permission?: Record - } = { - description: generated.whenToUse, - mode, - } - if (Object.keys(permissions).length > 0) { - frontmatter.permission = permissions - } + // Generate agent + const spinner = prompts.spinner() + spinner.start("Generating agent configuration...") + const model = args.model ? Provider.parseModel(args.model) : undefined + const generated = await AppRuntime.runPromise( + Agent.Service.use((svc) => svc.generate({ description, model })), + ).catch((error) => { + spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) + if (isFullyNonInteractive) process.exit(1) + throw new UI.CancelledError() + }) + spinner.stop(`Agent ${generated.identifier} generated`) - // Write file - const content = matter.stringify(generated.systemPrompt, frontmatter) - const filePath = path.join(targetPath, `${generated.identifier}.md`) + // Select permissions to allow + let selected: string[] + if (perms !== undefined) { + selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS + } else { + const result = await prompts.multiselect({ + message: "Select permissions to allow (Space to toggle)", + options: AVAILABLE_PERMISSIONS.map((permission) => ({ + label: permission, + value: permission, + })), + initialValues: AVAILABLE_PERMISSIONS, + }) + if (prompts.isCancel(result)) throw new UI.CancelledError() + selected = result + } - await fs.mkdir(targetPath, { recursive: true }) + // Get mode + let mode: AgentMode + if (cliMode) { + mode = cliMode + } else { + const modeResult = await prompts.select({ + message: "Agent mode", + options: [ + { + label: "All", + value: "all" as const, + hint: "Can function in both primary and subagent roles", + }, + { + label: "Primary", + value: "primary" as const, + hint: "Acts as a primary/main agent", + }, + { + label: "Subagent", + value: "subagent" as const, + hint: "Can be used as a subagent by other agents", + }, + ], + initialValue: "all" as const, + }) + if (prompts.isCancel(modeResult)) throw new UI.CancelledError() + mode = modeResult + } - if (await Filesystem.exists(filePath)) { - if (isFullyNonInteractive) { - console.error(`Error: Agent file already exists: ${filePath}`) - process.exit(1) - } - prompts.log.error(`Agent file already exists: ${filePath}`) - throw new UI.CancelledError() + // Build permissions config — deny anything not explicitly selected. + const permissions: Record = {} + for (const permission of AVAILABLE_PERMISSIONS) { + if (!selected.includes(permission)) { + permissions[permission] = "deny" } + } - await Filesystem.write(filePath, content) + // Build frontmatter + const frontmatter: { + description: string + mode: AgentMode + permission?: Record + } = { + description: generated.whenToUse, + mode, + } + if (Object.keys(permissions).length > 0) { + frontmatter.permission = permissions + } + + // Write file + const content = matter.stringify(generated.systemPrompt, frontmatter) + const filePath = path.join(targetPath, `${generated.identifier}.md`) + + await fs.mkdir(targetPath, { recursive: true }) + if (await Filesystem.exists(filePath)) { if (isFullyNonInteractive) { - console.log(filePath) - } else { - prompts.log.success(`Agent created: ${filePath}`) - prompts.outro("Done") + console.error(`Error: Agent file already exists: ${filePath}`) + process.exit(1) } + prompts.log.error(`Agent file already exists: ${filePath}`) + throw new UI.CancelledError() + } + + await Filesystem.write(filePath, content) + + if (isFullyNonInteractive) { + console.log(filePath) + } else { + prompts.log.success(`Agent created: ${filePath}`) + prompts.outro("Done") + } }) }), }) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index d1e8b33be7..d9927e287f 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -440,158 +440,158 @@ export const McpAddCommand = effectCmd({ if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx yield* Effect.promise(async () => { - UI.empty() - prompts.intro("Add MCP server") - - const project = ctx.project - - // Resolve config paths eagerly for hints - const [projectConfigPath, globalConfigPath] = await Promise.all([ - resolveConfigPath(ctx.worktree), - resolveConfigPath(Global.Path.config, true), - ]) - - // Determine scope - let configPath = globalConfigPath - if (project.vcs === "git") { - const scopeResult = await prompts.select({ - message: "Location", - options: [ - { - label: "Current project", - value: projectConfigPath, - hint: projectConfigPath, - }, - { - label: "Global", - value: globalConfigPath, - hint: globalConfigPath, - }, - ], - }) - if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() - configPath = scopeResult - } - - const name = await prompts.text({ - message: "Enter MCP server name", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(name)) throw new UI.CancelledError() - - const type = await prompts.select({ - message: "Select MCP server type", + UI.empty() + prompts.intro("Add MCP server") + + const project = ctx.project + + // Resolve config paths eagerly for hints + const [projectConfigPath, globalConfigPath] = await Promise.all([ + resolveConfigPath(ctx.worktree), + resolveConfigPath(Global.Path.config, true), + ]) + + // Determine scope + let configPath = globalConfigPath + if (project.vcs === "git") { + const scopeResult = await prompts.select({ + message: "Location", options: [ { - label: "Local", - value: "local", - hint: "Run a local command", + label: "Current project", + value: projectConfigPath, + hint: projectConfigPath, }, { - label: "Remote", - value: "remote", - hint: "Connect to a remote URL", + label: "Global", + value: globalConfigPath, + hint: globalConfigPath, }, ], }) - if (prompts.isCancel(type)) throw new UI.CancelledError() + if (prompts.isCancel(scopeResult)) throw new UI.CancelledError() + configPath = scopeResult + } - if (type === "local") { - const command = await prompts.text({ - message: "Enter command to run", - placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(command)) throw new UI.CancelledError() + const name = await prompts.text({ + message: "Enter MCP server name", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(name)) throw new UI.CancelledError() + + const type = await prompts.select({ + message: "Select MCP server type", + options: [ + { + label: "Local", + value: "local", + hint: "Run a local command", + }, + { + label: "Remote", + value: "remote", + hint: "Connect to a remote URL", + }, + ], + }) + if (prompts.isCancel(type)) throw new UI.CancelledError() - const mcpConfig: ConfigMCP.Info = { - type: "local", - command: command.split(" "), - } + if (type === "local") { + const command = await prompts.text({ + message: "Enter command to run", + placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(command)) throw new UI.CancelledError() - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) - prompts.outro("MCP server added successfully") - return + const mcpConfig: ConfigMCP.Info = { + type: "local", + command: command.split(" "), } - if (type === "remote") { - const url = await prompts.text({ - message: "Enter MCP server URL", - placeholder: "e.g., https://example.com/mcp", - validate: (x) => { - if (!x) return "Required" - if (x.length === 0) return "Required" - const isValid = URL.canParse(x) - return isValid ? undefined : "Invalid URL" - }, - }) - if (prompts.isCancel(url)) throw new UI.CancelledError() + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + prompts.outro("MCP server added successfully") + return + } - const useOAuth = await prompts.confirm({ - message: "Does this server require OAuth authentication?", + if (type === "remote") { + const url = await prompts.text({ + message: "Enter MCP server URL", + placeholder: "e.g., https://example.com/mcp", + validate: (x) => { + if (!x) return "Required" + if (x.length === 0) return "Required" + const isValid = URL.canParse(x) + return isValid ? undefined : "Invalid URL" + }, + }) + if (prompts.isCancel(url)) throw new UI.CancelledError() + + const useOAuth = await prompts.confirm({ + message: "Does this server require OAuth authentication?", + initialValue: false, + }) + if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + + let mcpConfig: ConfigMCP.Info + + if (useOAuth) { + const hasClientId = await prompts.confirm({ + message: "Do you have a pre-registered client ID?", initialValue: false, }) - if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() + if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() - let mcpConfig: ConfigMCP.Info + if (hasClientId) { + const clientId = await prompts.text({ + message: "Enter client ID", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(clientId)) throw new UI.CancelledError() - if (useOAuth) { - const hasClientId = await prompts.confirm({ - message: "Do you have a pre-registered client ID?", + const hasSecret = await prompts.confirm({ + message: "Do you have a client secret?", initialValue: false, }) - if (prompts.isCancel(hasClientId)) throw new UI.CancelledError() + if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - if (hasClientId) { - const clientId = await prompts.text({ - message: "Enter client ID", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), + let clientSecret: string | undefined + if (hasSecret) { + const secret = await prompts.password({ + message: "Enter client secret", }) - if (prompts.isCancel(clientId)) throw new UI.CancelledError() - - const hasSecret = await prompts.confirm({ - message: "Do you have a client secret?", - initialValue: false, - }) - if (prompts.isCancel(hasSecret)) throw new UI.CancelledError() - - let clientSecret: string | undefined - if (hasSecret) { - const secret = await prompts.password({ - message: "Enter client secret", - }) - if (prompts.isCancel(secret)) throw new UI.CancelledError() - clientSecret = secret - } + if (prompts.isCancel(secret)) throw new UI.CancelledError() + clientSecret = secret + } - mcpConfig = { - type: "remote", - url, - oauth: { - clientId, - ...(clientSecret && { clientSecret }), - }, - } - } else { - mcpConfig = { - type: "remote", - url, - oauth: {}, - } + mcpConfig = { + type: "remote", + url, + oauth: { + clientId, + ...(clientSecret && { clientSecret }), + }, } } else { mcpConfig = { type: "remote", url, + oauth: {}, } } - - await addMcpToConfig(name, mcpConfig, configPath) - prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } else { + mcpConfig = { + type: "remote", + url, + } } - prompts.outro("MCP server added successfully") + await addMcpToConfig(name, mcpConfig, configPath) + prompts.log.success(`MCP server "${name}" added to ${configPath}`) + } + + prompts.outro("MCP server added successfully") }) }), }) @@ -607,177 +607,177 @@ export const McpDebugCommand = effectCmd({ }), handler: Effect.fn("Cli.mcp.debug")(function* (args) { yield* Effect.promise(async () => { - UI.empty() - prompts.intro("MCP OAuth Debug") - - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - const mcpServers = config.mcp ?? {} - const serverName = args.name - - const serverConfig = mcpServers[serverName] - if (!serverConfig) { - prompts.log.error(`MCP server not found: ${serverName}`) - prompts.outro("Done") - return - } + UI.empty() + prompts.intro("MCP OAuth Debug") - if (!isMcpRemote(serverConfig)) { - prompts.log.error(`MCP server ${serverName} is not a remote server`) - prompts.outro("Done") - return - } + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + const mcpServers = config.mcp ?? {} + const serverName = args.name - if (serverConfig.oauth === false) { - prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) - prompts.outro("Done") - return - } + const serverConfig = mcpServers[serverName] + if (!serverConfig) { + prompts.log.error(`MCP server not found: ${serverName}`) + prompts.outro("Done") + return + } - prompts.log.info(`Server: ${serverName}`) - prompts.log.info(`URL: ${serverConfig.url}`) + if (!isMcpRemote(serverConfig)) { + prompts.log.error(`MCP server ${serverName} is not a remote server`) + prompts.outro("Done") + return + } - // Check stored auth status - const { authStatus, entry } = await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - const auth = yield* McpAuth.Service - return { - authStatus: yield* mcp.getAuthStatus(serverName), - entry: yield* auth.get(serverName), - } - }), - ) - prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) - - if (entry?.tokens) { - prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) - if (entry.tokens.expiresAt) { - const expiresDate = new Date(entry.tokens.expiresAt * 1000) - const isExpired = entry.tokens.expiresAt < Date.now() / 1000 - prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) - } - if (entry.tokens.refreshToken) { - prompts.log.info(` Refresh token: present`) + if (serverConfig.oauth === false) { + prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`) + prompts.outro("Done") + return + } + + prompts.log.info(`Server: ${serverName}`) + prompts.log.info(`URL: ${serverConfig.url}`) + + // Check stored auth status + const { authStatus, entry } = await AppRuntime.runPromise( + Effect.gen(function* () { + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service + return { + authStatus: yield* mcp.getAuthStatus(serverName), + entry: yield* auth.get(serverName), } + }), + ) + prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) + + if (entry?.tokens) { + prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`) + if (entry.tokens.expiresAt) { + const expiresDate = new Date(entry.tokens.expiresAt * 1000) + const isExpired = entry.tokens.expiresAt < Date.now() / 1000 + prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`) } - if (entry?.clientInfo) { - prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) - if (entry.clientInfo.clientSecretExpiresAt) { - const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) - prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) - } + if (entry.tokens.refreshToken) { + prompts.log.info(` Refresh token: present`) } + } + if (entry?.clientInfo) { + prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`) + if (entry.clientInfo.clientSecretExpiresAt) { + const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000) + prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`) + } + } - const spinner = prompts.spinner() - spinner.start("Testing connection...") - - // Test basic HTTP connectivity first - try { - const response = await fetch(serverConfig.url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", + const spinner = prompts.spinner() + spinner.start("Testing connection...") + + // Test basic HTTP connectivity first + try { + const response = await fetch(serverConfig.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "opencode-debug", version: InstallationVersion }, }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "opencode-debug", version: InstallationVersion }, - }, - id: 1, - }), - }) + id: 1, + }), + }) - spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) + spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) - // Check for WWW-Authenticate header - const wwwAuth = response.headers.get("www-authenticate") - if (wwwAuth) { - prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) - } + // Check for WWW-Authenticate header + const wwwAuth = response.headers.get("www-authenticate") + if (wwwAuth) { + prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) + } - if (response.status === 401) { - prompts.log.warn("Server returned 401 Unauthorized") - - // Try to discover OAuth metadata - const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - const auth = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* McpAuth.Service - }), - ) - const authProvider = new McpOAuthProvider( - serverName, - serverConfig.url, - { - clientId: oauthConfig?.clientId, - clientSecret: oauthConfig?.clientSecret, - scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, - }, - { - onRedirect: async () => {}, - }, - auth, - ) + if (response.status === 401) { + prompts.log.warn("Server returned 401 Unauthorized") - prompts.log.info("Testing OAuth flow (without completing authorization)...") + // Try to discover OAuth metadata + const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + const auth = await AppRuntime.runPromise( + Effect.gen(function* () { + return yield* McpAuth.Service + }), + ) + const authProvider = new McpOAuthProvider( + serverName, + serverConfig.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async () => {}, + }, + auth, + ) - // Try creating transport with auth provider to trigger discovery - const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { - authProvider, - }) + prompts.log.info("Testing OAuth flow (without completing authorization)...") - try { - const client = new Client({ - name: "opencode-debug", - version: InstallationVersion, - }) - await client.connect(transport) - prompts.log.success("Connection successful (already authenticated)") - await client.close() - } catch (error) { - if (error instanceof UnauthorizedError) { - prompts.log.info(`OAuth flow triggered: ${error.message}`) - - // Check if dynamic registration would be attempted - const clientInfo = await authProvider.clientInformation() - if (clientInfo) { - prompts.log.info(`Client ID available: ${clientInfo.client_id}`) - } else { - prompts.log.info("No client ID - dynamic registration will be attempted") - } + // Try creating transport with auth provider to trigger discovery + const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { + authProvider, + }) + + try { + const client = new Client({ + name: "opencode-debug", + version: InstallationVersion, + }) + await client.connect(transport) + prompts.log.success("Connection successful (already authenticated)") + await client.close() + } catch (error) { + if (error instanceof UnauthorizedError) { + prompts.log.info(`OAuth flow triggered: ${error.message}`) + + // Check if dynamic registration would be attempted + const clientInfo = await authProvider.clientInformation() + if (clientInfo) { + prompts.log.info(`Client ID available: ${clientInfo.client_id}`) } else { - prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) - } - } - } else if (response.status >= 200 && response.status < 300) { - prompts.log.success("Server responded successfully (no auth required or already authenticated)") - const body = await response.text() - try { - const json = JSON.parse(body) - if (json.result?.serverInfo) { - prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) + prompts.log.info("No client ID - dynamic registration will be attempted") } - } catch { - // Not JSON, ignore + } else { + prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) } - } else { - prompts.log.warn(`Unexpected status: ${response.status}`) - const body = await response.text().catch(() => "") - if (body) { - prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } + } else if (response.status >= 200 && response.status < 300) { + prompts.log.success("Server responded successfully (no auth required or already authenticated)") + const body = await response.text() + try { + const json = JSON.parse(body) + if (json.result?.serverInfo) { + prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) } + } catch { + // Not JSON, ignore + } + } else { + prompts.log.warn(`Unexpected status: ${response.status}`) + const body = await response.text().catch(() => "") + if (body) { + prompts.log.info(`Response body: ${body.substring(0, 500)}`) } - } catch (error) { - spinner.stop("Connection failed", 1) - prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) } + } catch (error) { + spinner.stop("Connection failed", 1) + prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + } - prompts.outro("Debug complete") + prompts.outro("Debug complete") }) }), }) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 93541114b4..71e03c7e79 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -240,49 +240,49 @@ export const ProvidersListCommand = effectCmd({ instance: false, handler: Effect.fn("Cli.providers.list")(function* (_args) { yield* Effect.promise(async () => { - UI.empty() - const authPath = path.join(Global.Path.data, "auth.json") - const homedir = os.homedir() - const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath - prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - const database = await getModels() + UI.empty() + const authPath = path.join(Global.Path.data, "auth.json") + const homedir = os.homedir() + const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath + prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + const results = await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + return Object.entries(yield* auth.all()) + }), + ) + const database = await getModels() - for (const [providerID, result] of results) { - const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) - } + for (const [providerID, result] of results) { + const name = database[providerID]?.name || providerID + prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + } - prompts.outro(`${results.length} credentials`) + prompts.outro(`${results.length} credentials`) - const activeEnvVars: Array<{ provider: string; envVar: string }> = [] + const activeEnvVars: Array<{ provider: string; envVar: string }> = [] - for (const [providerID, provider] of Object.entries(database)) { - for (const envVar of provider.env) { - if (process.env[envVar]) { - activeEnvVars.push({ - provider: provider.name || providerID, - envVar, - }) + for (const [providerID, provider] of Object.entries(database)) { + for (const envVar of provider.env) { + if (process.env[envVar]) { + activeEnvVars.push({ + provider: provider.name || providerID, + envVar, + }) + } } } - } - if (activeEnvVars.length > 0) { - UI.empty() - prompts.intro("Environment") + if (activeEnvVars.length > 0) { + UI.empty() + prompts.intro("Environment") - for (const { provider, envVar } of activeEnvVars) { - prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) - } + for (const { provider, envVar } of activeEnvVars) { + prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) + } - prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) - } + prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) + } }) }), }) @@ -308,187 +308,187 @@ export const ProvidersLoginCommand = effectCmd({ }), handler: Effect.fn("Cli.providers.login")(function* (args) { yield* Effect.promise(async () => { - UI.empty() - prompts.intro("Add credential") - if (args.url) { - const url = args.url.replace(/\/+$/, "") - const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { - auth: { command: string[]; env: string } - } - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { - stdout: "pipe", - }) - if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) - if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - await put(url, { - type: "wellknown", - key: wellknown.auth.env, - token: token.trim(), - }) - prompts.log.success("Logged into " + url) + UI.empty() + prompts.intro("Add credential") + if (args.url) { + const url = args.url.replace(/\/+$/, "") + const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { + auth: { command: string[]; env: string } + } + prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const proc = Process.spawn(wellknown.auth.command, { + stdout: "pipe", + }) + if (!proc.stdout) { + prompts.log.error("Failed") + prompts.outro("Done") + return + } + const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) + if (exit !== 0) { + prompts.log.error("Failed") prompts.outro("Done") return } - await refreshModels().catch(() => {}) + await put(url, { + type: "wellknown", + key: wellknown.auth.env, + token: token.trim(), + }) + prompts.log.success("Logged into " + url) + prompts.outro("Done") + return + } + await refreshModels().catch(() => {}) - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const providers = await getModels().then((x) => { - const filtered: Record = {} - for (const [key, value] of Object.entries(x)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } + const providers = await getModels().then((x) => { + const filtered: Record = {} + for (const [key, value] of Object.entries(x)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { + filtered[key] = value } - return filtered - }) - const hooks = await AppRuntime.runPromise( - Effect.gen(function* () { - const plugin = yield* Plugin.Service - return yield* plugin.list() - }), - ) - - const priority: Record = { - opencode: 0, - openai: 1, - "github-copilot": 2, - google: 3, - anthropic: 4, - openrouter: 5, - vercel: 6, } - const pluginProviders = resolvePluginProviders({ - hooks, - existingProviders: providers, - disabled, - enabled, - providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), - }) - const options = [ - ...pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: { - opencode: "recommended", - openai: "ChatGPT Plus/Pro or API key", - }[x.id], - })), + return filtered + }) + const hooks = await AppRuntime.runPromise( + Effect.gen(function* () { + const plugin = yield* Plugin.Service + return yield* plugin.list() + }), + ) + + const priority: Record = { + opencode: 0, + openai: 1, + "github-copilot": 2, + google: 3, + anthropic: 4, + openrouter: 5, + vercel: 6, + } + const pluginProviders = resolvePluginProviders({ + hooks, + existingProviders: providers, + disabled, + enabled, + providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), + }) + const options = [ + ...pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, ), - ...pluginProviders.map((x) => ({ + map((x) => ({ label: x.name, value: x.id, - hint: "plugin", + hint: { + opencode: "recommended", + openai: "ChatGPT Plus/Pro or API key", + }[x.id], })), - ] - - let provider: string - if (args.provider) { - const input = args.provider - const byID = options.find((x) => x.value === input) - const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) - const match = byID ?? byName - if (!match) { - prompts.log.error(`Unknown provider "${input}"`) - process.exit(1) - } - provider = match.value - } else { - const selected = await prompts.autocomplete({ - message: "Select provider", - maxItems: 8, - options: [ - ...options, - { - value: "other", - label: "Other", - }, - ], - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - provider = selected as string - } - - const plugin = hooks.findLast((x) => x.auth?.provider === provider) - if (plugin && plugin.auth) { - const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) - if (handled) return + ), + ...pluginProviders.map((x) => ({ + label: x.name, + value: x.id, + hint: "plugin", + })), + ] + + let provider: string + if (args.provider) { + const input = args.provider + const byID = options.find((x) => x.value === input) + const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) + const match = byID ?? byName + if (!match) { + prompts.log.error(`Unknown provider "${input}"`) + process.exit(1) } + provider = match.value + } else { + const selected = await prompts.autocomplete({ + message: "Select provider", + maxItems: 8, + options: [ + ...options, + { + value: "other", + label: "Other", + }, + ], + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + provider = selected as string + } - if (provider === "other") { - const custom = await prompts.text({ - message: "Enter provider id", - validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), - }) - if (prompts.isCancel(custom)) throw new UI.CancelledError() - provider = custom.replace(/^@ai-sdk\//, "") + const plugin = hooks.findLast((x) => x.auth?.provider === provider) + if (plugin && plugin.auth) { + const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) + if (handled) return + } - const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) - if (customPlugin && customPlugin.auth) { - const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) - if (handled) return - } + if (provider === "other") { + const custom = await prompts.text({ + message: "Enter provider id", + validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), + }) + if (prompts.isCancel(custom)) throw new UI.CancelledError() + provider = custom.replace(/^@ai-sdk\//, "") - prompts.log.warn( - `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, - ) + const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) + if (customPlugin && customPlugin.auth) { + const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) + if (handled) return } - if (provider === "amazon-bedrock") { - prompts.log.info( - "Amazon Bedrock authentication priority:\n" + - " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + - " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + - "Configure via opencode.json options (profile, region, endpoint) or\n" + - "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", - ) - } + prompts.log.warn( + `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, + ) + } - if (provider === "opencode") { - prompts.log.info("Create an api key at https://opencode.ai/auth") - } + if (provider === "amazon-bedrock") { + prompts.log.info( + "Amazon Bedrock authentication priority:\n" + + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + + "Configure via opencode.json options (profile, region, endpoint) or\n" + + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", + ) + } - if (provider === "vercel") { - prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") - } + if (provider === "opencode") { + prompts.log.info("Create an api key at https://opencode.ai/auth") + } - if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { - prompts.log.info( - "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", - ) - } + if (provider === "vercel") { + prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") + } - const key = await prompts.password({ - message: "Enter your API key", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(key)) throw new UI.CancelledError() - await put(provider, { - type: "api", - key, - }) + if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { + prompts.log.info( + "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", + ) + } - prompts.outro("Done") + const key = await prompts.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + if (prompts.isCancel(key)) throw new UI.CancelledError() + await put(provider, { + type: "api", + key, + }) + + prompts.outro("Done") }) }), }) @@ -500,35 +500,35 @@ export const ProvidersLogoutCommand = effectCmd({ instance: false, handler: Effect.fn("Cli.providers.logout")(function* (_args) { yield* Effect.promise(async () => { - UI.empty() - const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - prompts.intro("Remove credential") - if (credentials.length === 0) { - prompts.log.error("No credentials found") - return - } - const database = await getModels() - const selected = await prompts.select({ - message: "Select provider", - options: credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })), - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - const providerID = selected as string - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.remove(providerID) - }), - ) - prompts.outro("Logout successful") + UI.empty() + const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + return Object.entries(yield* auth.all()) + }), + ) + prompts.intro("Remove credential") + if (credentials.length === 0) { + prompts.log.error("No credentials found") + return + } + const database = await getModels() + const selected = await prompts.select({ + message: "Select provider", + options: credentials.map(([key, value]) => ({ + label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", + value: key, + })), + }) + if (prompts.isCancel(selected)) throw new UI.CancelledError() + const providerID = selected as string + await AppRuntime.runPromise( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.remove(providerID) + }), + ) + prompts.outro("Logout successful") }) }), }) From 33312bfd1b32745417fc56928d46f384ead2e10b Mon Sep 17 00:00:00 2001 From: Dax Date: Sat, 2 May 2026 23:24:46 -0400 Subject: [PATCH 090/178] fix(session): encode v2 session responses (#25528) --- packages/opencode/src/v2/session.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 1777b875aa..1f4cbcf1e0 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -11,6 +11,7 @@ import { ProjectID } from "@/project/schema" import { ModelID, ProviderID } from "@/provider/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" +import { optionalOmitUndefined } from "@/util/schema" export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ identifier: "Session.Delivery", @@ -21,20 +22,20 @@ export const DefaultDelivery = "immediate" satisfies Delivery export class Info extends Schema.Class("Session.Info")({ id: SessionID, - parentID: SessionID.pipe(Schema.optional), + parentID: optionalOmitUndefined(SessionID), projectID: ProjectID, - workspaceID: WorkspaceID.pipe(Schema.optional), - path: Schema.String.pipe(Schema.optional), - agent: Schema.String.pipe(Schema.optional), + workspaceID: optionalOmitUndefined(WorkspaceID), + path: optionalOmitUndefined(Schema.String), + agent: optionalOmitUndefined(Schema.String), model: Schema.Struct({ id: ModelID, providerID: ProviderID, - variant: Schema.String.pipe(Schema.optional), - }).pipe(Schema.optional), + variant: optionalOmitUndefined(Schema.String), + }).pipe(optionalOmitUndefined), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis, - archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), + archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis), }), title: Schema.String, /* @@ -109,7 +110,7 @@ export const layer = Layer.effect( decodeMessage({ ...row.data, id: row.id, type: row.type }) function fromRow(row: typeof SessionTable.$inferSelect): Info { - return { + return new Info({ id: SessionID.make(row.id), projectID: ProjectID.make(row.project_id), workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined, @@ -129,7 +130,7 @@ export const layer = Layer.effect( updated: DateTime.makeUnsafe(row.time_updated), archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, }, - } + }) } const result: Interface = { From b89d48a2a45520c6b3cb451a7860a5c2d6cab6ff Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 03:25:46 +0000 Subject: [PATCH 091/178] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index bea97a0cb3..84c3b13043 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=", - "aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=", - "aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=", - "x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U=" + "x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=", + "aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=", + "aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=", + "x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI=" } } From 8e016b4703a37dadaafc5de0a1ba17176b1a06a0 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sat, 2 May 2026 22:36:02 -0500 Subject: [PATCH 092/178] fix: regression w/ auth login where stderr was ignored instead of inherited (#25529) --- packages/opencode/src/cli/cmd/providers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 71e03c7e79..3dce55d324 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -318,6 +318,7 @@ export const ProvidersLoginCommand = effectCmd({ prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", + stderr: "inherit", }) if (!proc.stdout) { prompts.log.error("Failed") From 1717d636a24c0100d36c39deacbd875e0fe93b40 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:40:59 -0400 Subject: [PATCH 093/178] =?UTF-8?q?refactor(cli/mcp+agent):=20Stage=204=20?= =?UTF-8?q?=E2=80=94=20drop=20AppRuntime.runPromise=20bridges=20(#25530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/agent.ts | 6 ++---- packages/opencode/src/cli/cmd/mcp.ts | 24 ++++++++---------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index a5bcd7873b..2026d82324 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -1,6 +1,5 @@ import { cmd } from "./cmd" import * as prompts from "@clack/prompts" -import { AppRuntime } from "@/effect/app-runtime" import { UI } from "../ui" import { Global } from "@opencode-ai/core/global" import { Agent } from "../../agent/agent" @@ -66,6 +65,7 @@ const AgentCreateCommand = effectCmd({ const maybeCtx = yield* InstanceRef if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx + const agentSvc = yield* Agent.Service yield* Effect.promise(async () => { const cliPath = args.path const cliDescription = args.description @@ -127,9 +127,7 @@ const AgentCreateCommand = effectCmd({ const spinner = prompts.spinner() spinner.start("Generating agent configuration...") const model = args.model ? Provider.parseModel(args.model) : undefined - const generated = await AppRuntime.runPromise( - Agent.Service.use((svc) => svc.generate({ description, model })), - ).catch((error) => { + const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => { spinner.stop(`LLM failed to generate agent: ${error.message}`, 1) if (isFullyNonInteractive) process.exit(1) throw new UI.CancelledError() diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index d9927e287f..2ae7cece6a 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -19,7 +19,6 @@ import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" import { Filesystem } from "@/util/filesystem" import { Bus } from "../../bus" -import { AppRuntime } from "../../effect/app-runtime" import { Effect } from "effect" function getAuthStatusIcon(status: MCP.AuthStatus): string { @@ -606,11 +605,13 @@ export const McpDebugCommand = effectCmd({ demandOption: true, }), handler: Effect.fn("Cli.mcp.debug")(function* (args) { + const config = yield* Config.Service.use((cfg) => cfg.get()) + const mcp = yield* MCP.Service + const auth = yield* McpAuth.Service yield* Effect.promise(async () => { UI.empty() prompts.intro("MCP OAuth Debug") - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) const mcpServers = config.mcp ?? {} const serverName = args.name @@ -636,15 +637,11 @@ export const McpDebugCommand = effectCmd({ prompts.log.info(`Server: ${serverName}`) prompts.log.info(`URL: ${serverConfig.url}`) - // Check stored auth status - const { authStatus, entry } = await AppRuntime.runPromise( - Effect.gen(function* () { - const mcp = yield* MCP.Service - const auth = yield* McpAuth.Service - return { - authStatus: yield* mcp.getAuthStatus(serverName), - entry: yield* auth.get(serverName), - } + // Check stored auth status — services already in hand, run inline. + const { authStatus, entry } = await Effect.runPromise( + Effect.all({ + authStatus: mcp.getAuthStatus(serverName), + entry: auth.get(serverName), }), ) prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`) @@ -704,11 +701,6 @@ export const McpDebugCommand = effectCmd({ // Try to discover OAuth metadata const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - const auth = await AppRuntime.runPromise( - Effect.gen(function* () { - return yield* McpAuth.Service - }), - ) const authProvider = new McpOAuthProvider( serverName, serverConfig.url, From bd32252a7e3570f4501d7e217ad2380536dea095 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 2 May 2026 23:42:40 -0400 Subject: [PATCH 094/178] =?UTF-8?q?refactor(cli/providers):=20Stage=204=20?= =?UTF-8?q?=E2=80=94=20drop=20inline=20AppRuntime.runPromise=20calls=20(#2?= =?UTF-8?q?5532)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/providers.ts | 40 +++++++--------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 3dce55d324..081bcece00 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -239,19 +239,16 @@ export const ProvidersListCommand = effectCmd({ // Lists global credentials + provider env vars; no project instance needed. instance: false, handler: Effect.fn("Cli.providers.list")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service yield* Effect.promise(async () => { UI.empty() const authPath = path.join(Global.Path.data, "auth.json") const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) - const database = await getModels() + const results = Object.entries(await Effect.runPromise(authSvc.all())) + const database = await Effect.runPromise(modelsDev.get()) for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID @@ -307,6 +304,8 @@ export const ProvidersLoginCommand = effectCmd({ type: "string", }), handler: Effect.fn("Cli.providers.login")(function* (args) { + const cfgSvc = yield* Config.Service + const pluginSvc = yield* Plugin.Service yield* Effect.promise(async () => { UI.empty() prompts.intro("Add credential") @@ -342,7 +341,7 @@ export const ProvidersLoginCommand = effectCmd({ } await refreshModels().catch(() => {}) - const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())) + const config = await Effect.runPromise(cfgSvc.get()) const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined @@ -356,12 +355,7 @@ export const ProvidersLoginCommand = effectCmd({ } return filtered }) - const hooks = await AppRuntime.runPromise( - Effect.gen(function* () { - const plugin = yield* Plugin.Service - return yield* plugin.list() - }), - ) + const hooks = await Effect.runPromise(pluginSvc.list()) const priority: Record = { opencode: 0, @@ -500,20 +494,17 @@ export const ProvidersLogoutCommand = effectCmd({ // Removes a global auth credential; no project instance needed. instance: false, handler: Effect.fn("Cli.providers.logout")(function* (_args) { + const authSvc = yield* Auth.Service + const modelsDev = yield* ModelsDev.Service yield* Effect.promise(async () => { UI.empty() - const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - return Object.entries(yield* auth.all()) - }), - ) + const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all())) prompts.intro("Remove credential") if (credentials.length === 0) { prompts.log.error("No credentials found") return } - const database = await getModels() + const database = await Effect.runPromise(modelsDev.get()) const selected = await prompts.select({ message: "Select provider", options: credentials.map(([key, value]) => ({ @@ -523,12 +514,7 @@ export const ProvidersLogoutCommand = effectCmd({ }) if (prompts.isCancel(selected)) throw new UI.CancelledError() const providerID = selected as string - await AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.remove(providerID) - }), - ) + await Effect.runPromise(authSvc.remove(providerID)) prompts.outro("Logout successful") }) }), From 2df8eda8a3baf8c624527995ae1adb4dc19a1071 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 00:24:33 -0400 Subject: [PATCH 095/178] fix(cli): bridge Instance.current ALS in effectCmd handlers (regression from #25522) (#25546) --- packages/opencode/src/cli/effect-cmd.ts | 27 ++++++----- .../test/cli/effect-cmd-instance-als.test.ts | 48 +++++++++++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/test/cli/effect-cmd-instance-als.test.ts diff --git a/packages/opencode/src/cli/effect-cmd.ts b/packages/opencode/src/cli/effect-cmd.ts index b0f6de16b7..ada5f8677d 100644 --- a/packages/opencode/src/cli/effect-cmd.ts +++ b/packages/opencode/src/cli/effect-cmd.ts @@ -3,6 +3,7 @@ import { Effect, Schema } from "effect" import { AppRuntime, type AppServices } from "@/effect/app-runtime" import { InstanceStore } from "@/project/instance-store" import { InstanceRef } from "@/effect/instance-ref" +import { Instance } from "@/project/instance" import { cmd, type WithDoubleDash } from "./cmd/cmd" /** @@ -82,17 +83,21 @@ export const effectCmd = (opts: EffectCmdOpts) => return } const directory = opts.directory?.(args) ?? process.cwd() - await AppRuntime.runPromise( - InstanceStore.Service.use((store) => - store.provide( - { directory }, - Effect.gen(function* () { - const ctx = yield* InstanceRef - const body = opts.handler(args) - return ctx ? yield* body.pipe(Effect.ensuring(store.dispose(ctx))) : yield* body - }), - ), - ), + // Two-phase: load ctx, then run body inside Instance.current ALS. + // Effect's InstanceRef is provided via fiber context, but that context is + // lost across `await` inside `Effect.promise(async () => ...)` callbacks + // — when handlers re-enter Effect via `AppRuntime.runPromise(svc.method())` + // there, attach() falls back to Instance.current ALS, which Node preserves + // across awaits. Matches the pre-effectCmd `bootstrap()` behavior. + const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory }).pipe(Effect.map((ctx) => ({ store, ctx })))), ) + try { + await Instance.restore(ctx, () => + AppRuntime.runPromise(opts.handler(args).pipe(Effect.provideService(InstanceRef, ctx))), + ) + } finally { + await AppRuntime.runPromise(store.dispose(ctx)) + } }, }) diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts new file mode 100644 index 0000000000..de6fed8daa --- /dev/null +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -0,0 +1,48 @@ +import { afterEach, expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "fs/promises" +import { Instance } from "../../src/project/instance" +import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture" + +afterEach(async () => { + await disposeAllInstances() +}) + +// Regression for PR #25522: when an effectCmd handler does +// `yield* Effect.promise(async () => { ... await runPromise(svcMethod) ... })`, +// the inner runPromise creates a fresh fiber after `await` whose Effect context +// has lost the outer InstanceRef. Services that read `InstanceState.context` +// then fall back to `Instance.current` ALS, which must be installed at the JS +// callback boundary (Node ALS persists across awaits, Effect's fiber context +// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap. +// Pins effect-cmd.ts directly: the pattern test below exercises the load + +// Instance.restore + dispose triple via the shared `provideTestInstance` fixture, +// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't +// fail it. This grep guards the actual production callsite. +test("effect-cmd.ts wraps the handler body in Instance.restore", async () => { + const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8") + expect(source).toContain("Instance.restore(ctx") +}) + +test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => { + await using dir = await tmpdir({ git: true }) + await provideTestInstance({ + directory: dir.path, + fn: () => + Effect.runPromise( + Effect.promise(async () => { + await new Promise((r) => setTimeout(r, 5)) + const current = await Effect.runPromise( + Effect.sync(() => { + try { + return Instance.current + } catch { + return undefined + } + }), + ) + expect(current?.directory).toBe(dir.path) + }), + ), + }) +}) From 9179bafd547d879c2b02bac10492eca7db2695fe Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 3 May 2026 01:04:52 -0400 Subject: [PATCH 096/178] Add debug info command (#25550) --- packages/opencode/src/cli/cmd/debug/index.ts | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 2603663fb4..6e2643f688 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -1,5 +1,10 @@ import { Global } from "@opencode-ai/core/global" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Flag } from "@opencode-ai/core/flag/flag" +import os from "os" import { Duration, Effect } from "effect" +import { Config } from "@/config/config" +import { ConfigPlugin } from "@/config/plugin" import { effectCmd } from "../../effect-cmd" import { cmd } from "../cmd" import { ConfigCommand } from "./config" @@ -26,6 +31,7 @@ export const DebugCommand = cmd({ .command(SnapshotCommand) .command(StartupCommand) .command(AgentCommand) + .command(InfoCommand) .command(PathsCommand) .command(WaitCommand) .demandCommand(), @@ -40,6 +46,34 @@ const WaitCommand = effectCmd({ }), }) +const InfoCommand = effectCmd({ + command: "info", + describe: "show debug information", + handler: Effect.fn("Cli.debug.info")(function* () { + const config = yield* Config.Service.use((cfg) => cfg.get()) + const termProgram = process.env.TERM_PROGRAM + ? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}` + : undefined + const terminal = [termProgram, process.env.TERM].filter((item): item is string => Boolean(item)).join(" / ") + + console.log(`opencode version: ${InstallationVersion}`) + console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`) + console.log(`terminal: ${terminal || "unknown"}`) + console.log("plugins:") + if (Flag.OPENCODE_PURE) { + console.log("external plugins disabled (--pure)") + return + } + if (!config.plugin_origins?.length) { + console.log("none") + return + } + for (const plugin of config.plugin_origins) { + console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`) + } + }), +}) + const PathsCommand = cmd({ command: "paths", describe: "show global paths (data, config, cache, state)", From fc57eb3b8e0844fe3dfffda9ce769d002c8f6993 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:05:36 -0400 Subject: [PATCH 097/178] ci --- .github/TEAM_MEMBERS | 1 - .opencode/agent/triage.md | 127 +++++++------------------------------- 2 files changed, 21 insertions(+), 107 deletions(-) diff --git a/.github/TEAM_MEMBERS b/.github/TEAM_MEMBERS index 3b8519d3bb..e5f8f000e0 100644 --- a/.github/TEAM_MEMBERS +++ b/.github/TEAM_MEMBERS @@ -11,6 +11,5 @@ MrMushrooooom nexxeln R44VC0RP rekram1-node -RhysSullivan thdxr simonklee diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index a77b92737b..f6f2130f04 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -14,127 +14,42 @@ Use your github-triage tool to triage issues. This file is the source of truth for ownership/routing rules. -## Labels +Assign issues by choosing the team with the strongest overlap, then assign a member from that team at random. -### windows +## Teams -Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows. +### TUI -- Use if they mention WSL too +Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance. -#### perf +- kommander +- simonklee -Performance-related issues: +### Desktop / Web -- Slow performance -- High RAM usage -- High CPU usage +Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems. -**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness. - -#### desktop - -Desktop app issues: - -- `opencode web` command -- The desktop app itself - -**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues. - -#### nix - -**Only** add if the issue explicitly mentions nix. - -If the issue does not mention nix, do not add nix. - -If the issue mentions nix, assign to `rekram1-node`. - -#### zen - -**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black". - -If the issue doesn't have "zen" or "opencode black" in it then don't add zen label - -#### core - -Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`. - -Examples: - -- LSP server behavior -- Harness behavior (agent + tools) -- Feature requests for server behavior -- Agent context construction -- API endpoints -- Provider integration issues -- New, broken, or poor-quality models - -#### acp - -If the issue mentions acp support, assign acp label. - -#### docs - -Add if the issue requests better documentation or docs updates. - -#### opentui - -TUI issues potentially caused by our underlying TUI library: - -- Keybindings not working -- Scroll speed issues (too fast/slow/laggy) -- Screen flickering -- Crashes with opentui in the log - -**Do not** add for general TUI bugs. +- Hona +- Brendonovich -When assigning to people here are the following rules: +### Core -Desktop / Web: -Use for desktop-labeled issues only. +Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, and larger architectural features. -- adamdotdevin -- iamdavidhill -- Brendonovich +- jlongster +- rekram1-node - nexxeln +- kitlangton + +### Inference -Zen: -ONLY assign if the issue will have the "zen" label. +OpenCode Zen, OpenCode Go, and billing issues. - fwang - MrMushrooooom -TUI (`packages/opencode/src/cli/cmd/tui/...`): - -- thdxr for TUI UX/UI product decisions and interaction flow -- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks -- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues - -Core (`packages/opencode/...`, excluding TUI subtree): - -- thdxr for sqlite/snapshot/memory bugs and larger architectural core features -- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable) -- rekram1-node for harness issues, provider issues, and other bug-squashing - -For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable. - -Docs: - -- R44VC0RP - -Windows: - -- Hona (assign any issue that mentions Windows or is likely Windows-specific) - -Determinism rules: - -- If title + body does not contain "zen", do not add the "zen" label -- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix" -- If title + body mentions nix/nixos, assign to `rekram1-node` -- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner - -In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random. +### Windows -ACP: +Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows. -- rekram1-node (assign any acp issues to rekram1-node) +- Hona From 7ccab8d2729bb804c94e49c62df521026a6f80f2 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:10:14 -0400 Subject: [PATCH 098/178] core: update triage agent to use qwen3.6-plus model for improved response quality --- .opencode/agent/triage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index f6f2130f04..a4c8454a9d 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -1,7 +1,7 @@ --- mode: primary hidden: true -model: opencode/minimax-m2.5 +model: opencode/qwen3.6-plus color: "#44BA81" tools: "*": false From a08e4c96514b791391c9b81ade129f6634ad57f7 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:21:17 -0400 Subject: [PATCH 099/178] core: simplify triage workflow to focus on issue ownership Switch triage agent to gpt-5.4-nano for faster issue assignment. Remove label management from the triage tool so it only assigns owners based on team ownership rules. This reduces noise in the issue tracker and ensures issues get to the right team member immediately without unnecessary labels. Update team structures to reflect current ownership and add script for processing unassigned issues. --- .opencode/agent/triage.md | 26 ++----- .opencode/tool/github-triage.ts | 78 +++---------------- script/triage-unassigned.ts | 129 ++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 87 deletions(-) create mode 100644 script/triage-unassigned.ts diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md index a4c8454a9d..03df339cb8 100644 --- a/.opencode/agent/triage.md +++ b/.opencode/agent/triage.md @@ -1,7 +1,7 @@ --- mode: primary hidden: true -model: opencode/qwen3.6-plus +model: opencode/gpt-5.4-nano color: "#44BA81" tools: "*": false @@ -14,7 +14,11 @@ Use your github-triage tool to triage issues. This file is the source of truth for ownership/routing rules. -Assign issues by choosing the team with the strongest overlap, then assign a member from that team at random. +Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team. + +Do not add labels to issues. Only assign an owner. + +When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows. ## Teams @@ -22,34 +26,18 @@ Assign issues by choosing the team with the strongest overlap, then assign a mem Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance. -- kommander -- simonklee - ### Desktop / Web Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems. -- Hona -- Brendonovich - ### Core -Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, and larger architectural features. - -- jlongster -- rekram1-node -- nexxeln -- kitlangton +Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features. ### Inference OpenCode Zen, OpenCode Go, and billing issues. -- fwang -- MrMushrooooom - ### Windows Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows. - -- Hona diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index 56886808a4..e03b1fdd9c 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -1,16 +1,14 @@ /// import { tool } from "@opencode-ai/plugin" + const TEAM = { - desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"], - zen: ["fwang", "MrMushrooooom"], - tui: ["kommander", "rekram1-node", "simonklee"], - core: ["kitlangton", "rekram1-node", "jlongster"], - docs: ["R44VC0RP"], + tui: ["kommander", "simonklee"], + desktop_web: ["Hona", "Brendonovich"], + core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"], + inference: ["fwang", "MrMushrooooom"], windows: ["Hona"], } as const -const ASSIGNEES = [...new Set(Object.values(TEAM).flat())] - function pick(items: readonly T[]) { return items[Math.floor(Math.random() * items.length)]! } @@ -38,79 +36,23 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) { } export default tool({ - description: `Use this tool to assign and/or label a GitHub issue. + description: `Use this tool to assign a GitHub issue. -Choose labels and assignee using the current triage policy and ownership rules. -Pick the most fitting labels for the issue and assign one owner. - -If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`, +Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`, args: { - assignee: tool.schema - .enum(ASSIGNEES as [string, ...string[]]) - .describe("The username of the assignee") - .default("rekram1-node"), - labels: tool.schema - .array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"])) - .describe("The labels(s) to add to the issue") - .default([]), + team: tool.schema.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]).describe("The owning team"), }, async execute(args) { const issue = getIssueNumber() const owner = "anomalyco" const repo = "opencode" - - const results: string[] = [] - let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))] - const web = labels.includes("web") - const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase() - const zen = /\bzen\b/.test(text) || text.includes("opencode black") - const nix = /\bnix(os)?\b/.test(text) - - if (labels.includes("nix") && !nix) { - labels = labels.filter((x) => x !== "nix") - results.push("Dropped label: nix (issue does not mention nix)") - } - - const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee - - if (labels.includes("zen") && !zen) { - throw new Error("Only add the zen label when issue title/body contains 'zen'") - } - - if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) { - throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln") - } - - if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) { - throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom") - } - - if (assignee === "Hona" && !labels.includes("windows")) { - throw new Error("Only windows issues should be assigned to Hona") - } - - if (assignee === "R44VC0RP" && !labels.includes("docs")) { - throw new Error("Only docs issues should be assigned to R44VC0RP") - } - - if (assignee === "kommander" && !labels.includes("opentui")) { - throw new Error("Only opentui issues should be assigned to kommander") - } + const assignee = pick(TEAM[args.team]) await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { method: "POST", body: JSON.stringify({ assignees: [assignee] }), }) - results.push(`Assigned @${assignee} to issue #${issue}`) - - if (labels.length > 0) { - await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, { - method: "POST", - body: JSON.stringify({ labels }), - }) - results.push(`Added labels: ${labels.join(", ")}`) - } - return results.join("\n") + return `Assigned @${assignee} from ${args.team} to issue #${issue}` }, }) diff --git a/script/triage-unassigned.ts b/script/triage-unassigned.ts new file mode 100644 index 0000000000..a71c6af318 --- /dev/null +++ b/script/triage-unassigned.ts @@ -0,0 +1,129 @@ +#!/usr/bin/env bun + +import { parseArgs } from "util" + +async function run(command: string, args: string[], options: Bun.SpawnOptions.OptionsObject = {}) { + const process = Bun.spawn([command, ...args], options) + const status = await process.exited + if (status !== 0) throw new Error(`${command} ${args.join(" ")} exited with ${status}`) + return process +} + +async function text(command: string, args: string[]) { + const process = await run(command, args, { stdout: "pipe", stderr: "inherit" }) + return new Response(process.stdout).text() +} + +async function main() { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + days: { type: "string", short: "d", default: "30" }, + limit: { type: "string", short: "l", default: "200" }, + "dry-run": { type: "boolean", default: false }, + help: { type: "boolean", short: "h", default: false }, + }, + }) + + if (values.help) { + console.log(` +Usage: bun script/triage-unassigned.ts [options] + +Triage open GitHub issues created in the last 30 days with no assignee. + +Options: + -d, --days Look back this many days (default: 30) + -l, --limit Maximum issues to process (default: 200) + --dry-run Print matching issues without running triage + -h, --help Show this help message + +Examples: + bun script/triage-unassigned.ts + bun script/triage-unassigned.ts --limit 3 + bun script/triage-unassigned.ts --dry-run +`) + process.exit(0) + } + + const days = Number(values.days) + const limit = Number(values.limit) + if (!Number.isInteger(days) || days < 1) throw new Error("--days must be a positive integer") + if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer") + + const created = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) + const query = `no:assignee created:>=${created}` + const issues = JSON.parse( + await text("gh", [ + "issue", + "list", + "--state", + "open", + "--search", + query, + "--limit", + String(limit), + "--json", + "number,title,body", + ]), + ) as Array<{ number: number; title: string; body?: string | null }> + + console.log(`Found ${issues.length} open unassigned issues created since ${created}`) + if (issues.length === 0) return + + if (values["dry-run"]) { + for (const issue of issues) console.log(`#${issue.number} ${issue.title}`) + return + } + + const githubToken = process.env.GITHUB_TOKEN || (await text("gh", ["auth", "token"])).trim() + const failures: Array<{ issue: number; error: string }> = [] + + for (const [index, issue] of issues.entries()) { + console.log(`\n[${index + 1}/${issues.length}] Triaging #${issue.number} ${issue.title}`) + const result = Bun.spawn( + [ + "opencode", + "run", + "--agent", + "triage", + `The following issue was just opened, triage it: + +Issue: #${issue.number} +Title: ${issue.title} + +Body: +${issue.body ?? ""}`, + ], + { + env: { + ...process.env, + GITHUB_TOKEN: githubToken, + ISSUE_NUMBER: String(issue.number), + ISSUE_TITLE: issue.title, + ISSUE_BODY: issue.body ?? "", + }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }, + ) + const status = await result.exited + + if (status === 0) { + console.log(`[${index + 1}/${issues.length}] Done #${issue.number}`) + continue + } + + failures.push({ issue: issue.number, error: `opencode exited with ${status}` }) + console.error(`[${index + 1}/${issues.length}] Failed #${issue.number}: opencode exited with ${status}`) + } + + console.log(`\nFinished triaging ${issues.length - failures.length}/${issues.length} issues`) + if (failures.length === 0) return + + console.error("Failures:") + for (const failure of failures) console.error(`#${failure.issue}: ${failure.error}`) + process.exit(1) +} + +void main() From e2afdc1202d95cece585fbab599672f747625b71 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 05:22:22 +0000 Subject: [PATCH 100/178] chore: generate --- .opencode/tool/github-triage.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index e03b1fdd9c..35db44641e 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -40,7 +40,9 @@ export default tool({ Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`, args: { - team: tool.schema.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]).describe("The owning team"), + team: tool.schema + .enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]) + .describe("The owning team"), }, async execute(args) { const issue = getIssueNumber() From 252e2f98e68f448c4a5ec86073e216052a89997e Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:31:34 -0400 Subject: [PATCH 101/178] ci: remove automatic labels from GitHub issue templates to allow manual triage --- .github/ISSUE_TEMPLATE/bug-report.yml | 1 - .github/ISSUE_TEMPLATE/feature-request.yml | 1 - .github/ISSUE_TEMPLATE/question.yml | 1 - 3 files changed, 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index fe1ec8409b..96234eb25d 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,6 +1,5 @@ name: Bug report description: Report an issue that should be fixed -labels: ["bug"] body: - type: textarea id: description diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml index 92e6c47570..42f1d3c51a 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yml +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -1,6 +1,5 @@ name: 🚀 Feature Request description: Suggest an idea, feature, or enhancement -labels: [discussion] title: "[FEATURE]:" body: diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index 2310bfcc86..8930ba693c 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -1,6 +1,5 @@ name: Question description: Ask a question -labels: ["question"] body: - type: textarea id: question From b205e104f6d8c2e1349545713ac79df64ffda730 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:53:22 -0400 Subject: [PATCH 102/178] ci: remove vouch-based contributor filtering workflows Removes the automated vouch system that filtered issues and PRs from non-vouched users. This simplifies the contribution process by removing the requirement for maintainers to manually vouch contributors before they can participate. --- .github/VOUCHED.td | 41 ------- .github/workflows/vouch-check-issue.yml | 116 -------------------- .github/workflows/vouch-check-pr.yml | 114 ------------------- .github/workflows/vouch-manage-by-issue.yml | 38 ------- 4 files changed, 309 deletions(-) delete mode 100644 .github/VOUCHED.td delete mode 100644 .github/workflows/vouch-check-issue.yml delete mode 100644 .github/workflows/vouch-check-pr.yml delete mode 100644 .github/workflows/vouch-manage-by-issue.yml diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td deleted file mode 100644 index 3f9df695aa..0000000000 --- a/.github/VOUCHED.td +++ /dev/null @@ -1,41 +0,0 @@ -# Vouched contributors for this project. -# -# See https://github.com/mitchellh/vouch for details. -# -# Syntax: -# - One handle per line (without @), sorted alphabetically. -# - Optional platform prefix: platform:username (e.g., github:user). -# - Denounce with minus prefix: -username or -platform:username. -# - Optional details after a space following the handle. -adamdotdevin --agusbasari29 AI PR slop -ariane-emory --atharvau AI review spamming literally every PR --borealbytes --carycooper777 --danieljoshuanazareth --danieljoshuanazareth --davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person -dmtrkovalenko -edemaine -fahreddinozcan --florianleibert -fwang -iamdavidhill -jayair -kitlangton -kommander --opencode2026 --opencodeengineer bot that spams issues -r44vc0rp -rekram1-node --ricardo-m-l --robinmordasiewicz -rubdos --saisharan0103 spamming ai prs -shantur -simonklee --spider-yamet clawdbot/llm psychosis, spam pinging the team --terisuke -thdxr --toastythebot diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml deleted file mode 100644 index 4c2aa960b2..0000000000 --- a/.github/workflows/vouch-check-issue.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: vouch-check-issue - -on: - issues: - types: [opened] - -permissions: - contents: read - issues: write - -jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Check if issue author is denounced - uses: actions/github-script@v7 - with: - script: | - const author = context.payload.issue.user.login; - const issueNumber = context.payload.issue.number; - - // Skip bots - if (author.endsWith('[bot]')) { - core.info(`Skipping bot: ${author}`); - return; - } - - // Read the VOUCHED.td file via API (no checkout needed) - let content; - try { - const response = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/VOUCHED.td', - }); - content = Buffer.from(response.data.content, 'base64').toString('utf-8'); - } catch (error) { - if (error.status === 404) { - core.info('No .github/VOUCHED.td file found, skipping check.'); - return; - } - throw error; - } - - // Parse the .td file for vouched and denounced users - const vouched = new Set(); - const denounced = new Map(); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const isDenounced = trimmed.startsWith('-'); - const rest = isDenounced ? trimmed.slice(1).trim() : trimmed; - if (!rest) continue; - - const spaceIdx = rest.indexOf(' '); - const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); - const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); - - // Handle platform:username or bare username - // Only match bare usernames or github: prefix (skip other platforms) - const colonIdx = handle.indexOf(':'); - if (colonIdx !== -1) { - const platform = handle.slice(0, colonIdx).toLowerCase(); - if (platform !== 'github') continue; - } - const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); - if (!username) continue; - - if (isDenounced) { - denounced.set(username.toLowerCase(), reason); - continue; - } - - vouched.add(username.toLowerCase()); - } - - // Check if the author is denounced - const reason = denounced.get(author.toLowerCase()); - if (reason !== undefined) { - // Author is denounced — close the issue - const body = 'This issue has been automatically closed.'; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - body, - }); - - await github.rest.issues.update({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - state: 'closed', - state_reason: 'not_planned', - }); - - core.info(`Closed issue #${issueNumber} from denounced user ${author}`); - return; - } - - // Author is positively vouched — add label - if (!vouched.has(author.toLowerCase())) { - core.info(`User ${author} is not denounced or vouched. Allowing issue.`); - return; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issueNumber, - labels: ['Vouched'], - }); - - core.info(`Added vouched label to issue #${issueNumber} from ${author}`); diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml deleted file mode 100644 index 51816dfb75..0000000000 --- a/.github/workflows/vouch-check-pr.yml +++ /dev/null @@ -1,114 +0,0 @@ -name: vouch-check-pr - -on: - pull_request_target: - types: [opened] - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Check if PR author is denounced - uses: actions/github-script@v7 - with: - script: | - const author = context.payload.pull_request.user.login; - const prNumber = context.payload.pull_request.number; - - // Skip bots - if (author.endsWith('[bot]')) { - core.info(`Skipping bot: ${author}`); - return; - } - - // Read the VOUCHED.td file via API (no checkout needed) - let content; - try { - const response = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: '.github/VOUCHED.td', - }); - content = Buffer.from(response.data.content, 'base64').toString('utf-8'); - } catch (error) { - if (error.status === 404) { - core.info('No .github/VOUCHED.td file found, skipping check.'); - return; - } - throw error; - } - - // Parse the .td file for vouched and denounced users - const vouched = new Set(); - const denounced = new Map(); - for (const line of content.split('\n')) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const isDenounced = trimmed.startsWith('-'); - const rest = isDenounced ? trimmed.slice(1).trim() : trimmed; - if (!rest) continue; - - const spaceIdx = rest.indexOf(' '); - const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); - const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); - - // Handle platform:username or bare username - // Only match bare usernames or github: prefix (skip other platforms) - const colonIdx = handle.indexOf(':'); - if (colonIdx !== -1) { - const platform = handle.slice(0, colonIdx).toLowerCase(); - if (platform !== 'github') continue; - } - const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); - if (!username) continue; - - if (isDenounced) { - denounced.set(username.toLowerCase(), reason); - continue; - } - - vouched.add(username.toLowerCase()); - } - - // Check if the author is denounced - const reason = denounced.get(author.toLowerCase()); - if (reason !== undefined) { - // Author is denounced — close the PR - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: 'This pull request has been automatically closed.', - }); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - state: 'closed', - }); - - core.info(`Closed PR #${prNumber} from denounced user ${author}`); - return; - } - - // Author is positively vouched — add label - if (!vouched.has(author.toLowerCase())) { - core.info(`User ${author} is not denounced or vouched. Allowing PR.`); - return; - } - - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - labels: ['Vouched'], - }); - - core.info(`Added vouched label to PR #${prNumber} from ${author}`); diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml deleted file mode 100644 index 79687639df..0000000000 --- a/.github/workflows/vouch-manage-by-issue.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: vouch-manage-by-issue - -on: - issue_comment: - types: [created] - -concurrency: - group: vouch-manage - cancel-in-progress: false - -permissions: - contents: write - issues: write - pull-requests: read - -jobs: - manage: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - fetch-depth: 0 - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - uses: mitchellh/vouch/action/manage-by-issue@main - with: - issue-id: ${{ github.event.issue.number }} - comment-id: ${{ github.event.comment.id }} - roles: admin,maintain,write - env: - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} From 4f7f90133d939e462e5c47549b6f39a7bdce6cdb Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:54:26 -0400 Subject: [PATCH 103/178] ci: stop sending daily community recap notifications --- .github/workflows/daily-issues-recap.yml | 170 ---------------------- .github/workflows/daily-pr-recap.yml | 173 ----------------------- 2 files changed, 343 deletions(-) delete mode 100644 .github/workflows/daily-issues-recap.yml delete mode 100644 .github/workflows/daily-pr-recap.yml diff --git a/.github/workflows/daily-issues-recap.yml b/.github/workflows/daily-issues-recap.yml deleted file mode 100644 index 31cf08233b..0000000000 --- a/.github/workflows/daily-issues-recap.yml +++ /dev/null @@ -1,170 +0,0 @@ -name: daily-issues-recap - -on: - schedule: - # Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving) - - cron: "0 23 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - daily-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - issues: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Generate daily issues recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh issue*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - # Get today's date range - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather today's issues - Search for all OPEN issues created today (${TODAY}) using: - gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500 - - IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) issues only. - - STEP 2: Analyze and categorize - For each issue created today, categorize it: - - **Severity Assessment:** - - CRITICAL: Crashes, data loss, security issues, blocks major functionality - - HIGH: Significant bugs affecting many users, important features broken - - MEDIUM: Bugs with workarounds, minor features broken - - LOW: Minor issues, cosmetic, nice-to-haves - - **Activity Assessment:** - - Note issues with high comment counts or engagement - - Note issues from repeat reporters (check if author has filed before) - - STEP 3: Cross-reference with existing issues - For issues that seem like feature requests or recurring bugs: - - Search for similar older issues to identify patterns - - Note if this is a frequently requested feature - - Identify any issues that are duplicates of long-standing requests - - STEP 4: Generate the recap - Create a structured recap with these sections: - - ===DISCORD_START=== - **Daily Issues Recap - ${TODAY}** - - **Summary Stats** - - Total issues opened today: [count] - - By category: [bugs/features/questions] - - **Critical/High Priority Issues** - [List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers] - - **Most Active/Discussed** - [Issues with significant engagement or from active community members] - - **Trending Topics** - [Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature'] - - **Duplicates & Related** - [Issues that relate to existing open issues] - ===DISCORD_END=== - - STEP 5: Format for Discord - Format the recap as a Discord-compatible message: - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report - - Use hyperlinked issue numbers with suppressed embeds: [#1234]() - - Group related issues on single lines where possible - - Add emoji sparingly for critical items only - - HARD LIMIT: Keep under 1800 characters total - - Skip sections that have nothing notable (e.g., if no critical issues, omit that section) - - Prioritize signal over completeness - only surface what matters - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt - - echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily recap to Discord" diff --git a/.github/workflows/daily-pr-recap.yml b/.github/workflows/daily-pr-recap.yml deleted file mode 100644 index 2f0f023cfd..0000000000 --- a/.github/workflows/daily-pr-recap.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: daily-pr-recap - -on: - schedule: - # Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving) - - cron: "0 22 * * *" - workflow_dispatch: # Allow manual trigger for testing - -jobs: - pr-recap: - runs-on: blacksmith-4vcpu-ubuntu-2404 - permissions: - contents: read - pull-requests: read - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - uses: ./.github/actions/setup-bun - - - name: Install opencode - run: curl -fsSL https://opencode.ai/install | bash - - - name: Generate daily PR recap - id: recap - env: - OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_PERMISSION: | - { - "bash": { - "*": "deny", - "gh pr*": "allow", - "gh search*": "allow" - }, - "webfetch": "deny", - "edit": "deny", - "write": "deny" - } - run: | - TODAY=$(date -u +%Y-%m-%d) - - opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository. - - TODAY'S DATE: ${TODAY} - - STEP 1: Gather PR data - Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}): - - # Open PRs created today - gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - # Open PRs with activity today (updated today) - gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - - IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these: - adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr - This recap is specifically for COMMUNITY (external) contributions only. - - - - STEP 2: For high-activity PRs, check comment counts - For promising PRs, run: - gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length' - - IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts: - - copilot-pull-request-reviewer - - github-actions - - STEP 3: Identify what matters (ONLY from today's PRs) - - **Bug Fixes From Today:** - - PRs with 'fix' or 'bug' in title created/updated today - - Small bug fixes (< 100 lines changed) that are easy to review - - Bug fixes from community contributors - - **High Activity Today:** - - PRs with significant human comments today (excluding bots listed above) - - PRs with back-and-forth discussion today - - **Quick Wins:** - - Small PRs (< 50 lines) that are approved or nearly approved - - PRs that just need a final review - - STEP 4: Generate the recap - Create a structured recap: - - ===DISCORD_START=== - **Daily PR Recap - ${TODAY}** - - **New PRs Today** - [PRs opened today - group by type: bug fixes, features, etc.] - - **Active PRs Today** - [PRs with activity/updates today - significant discussion] - - **Quick Wins** - [Small PRs ready to merge] - ===DISCORD_END=== - - STEP 5: Format for Discord - - Use Discord markdown (**, __, etc.) - - BE EXTREMELY CONCISE - surface what we might miss - - Use hyperlinked PR numbers with suppressed embeds: [#1234]() - - Include PR author: [#1234]() (@author) - - For bug fixes, add brief description of what it fixes - - Show line count for quick wins: \"(+15/-3 lines)\" - - HARD LIMIT: Keep under 1800 characters total - - Skip empty sections - - Focus on PRs that need human eyes - - OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt - - # Extract only the Discord message between markers - sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt - - echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT - - - name: Post to Discord - env: - DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} - run: | - if [ -z "$DISCORD_WEBHOOK_URL" ]; then - echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post" - cat /tmp/pr_recap.txt - exit 0 - fi - - # Read the recap - RECAP_RAW=$(cat /tmp/pr_recap.txt) - RECAP_LENGTH=${#RECAP_RAW} - - echo "Recap length: ${RECAP_LENGTH} chars" - - # Function to post a message to Discord - post_to_discord() { - local msg="$1" - local content=$(echo "$msg" | jq -Rs '.') - curl -s -H "Content-Type: application/json" \ - -X POST \ - -d "{\"content\": ${content}}" \ - "$DISCORD_WEBHOOK_URL" - sleep 1 - } - - # If under limit, send as single message - if [ "$RECAP_LENGTH" -le 1950 ]; then - post_to_discord "$RECAP_RAW" - else - echo "Splitting into multiple messages..." - remaining="$RECAP_RAW" - while [ ${#remaining} -gt 0 ]; do - if [ ${#remaining} -le 1950 ]; then - post_to_discord "$remaining" - break - else - chunk="${remaining:0:1900}" - last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1) - if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then - chunk="${remaining:0:$last_newline}" - remaining="${remaining:$((last_newline+1))}" - else - chunk="${remaining:0:1900}" - remaining="${remaining:1900}" - fi - post_to_discord "$chunk" - fi - done - fi - - echo "Posted daily PR recap to Discord" From 8299fb3e2b1720b557da56ab9d7505ace7f53fce Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 3 May 2026 01:59:03 -0400 Subject: [PATCH 104/178] ignore: remove triage-unassigned.ts script This script was used to batch-triage open GitHub issues without assignees. Removing as the triage workflow has evolved and this batch approach is no longer needed. --- script/triage-unassigned.ts | 129 ------------------------------------ 1 file changed, 129 deletions(-) delete mode 100644 script/triage-unassigned.ts diff --git a/script/triage-unassigned.ts b/script/triage-unassigned.ts deleted file mode 100644 index a71c6af318..0000000000 --- a/script/triage-unassigned.ts +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env bun - -import { parseArgs } from "util" - -async function run(command: string, args: string[], options: Bun.SpawnOptions.OptionsObject = {}) { - const process = Bun.spawn([command, ...args], options) - const status = await process.exited - if (status !== 0) throw new Error(`${command} ${args.join(" ")} exited with ${status}`) - return process -} - -async function text(command: string, args: string[]) { - const process = await run(command, args, { stdout: "pipe", stderr: "inherit" }) - return new Response(process.stdout).text() -} - -async function main() { - const { values } = parseArgs({ - args: Bun.argv.slice(2), - options: { - days: { type: "string", short: "d", default: "30" }, - limit: { type: "string", short: "l", default: "200" }, - "dry-run": { type: "boolean", default: false }, - help: { type: "boolean", short: "h", default: false }, - }, - }) - - if (values.help) { - console.log(` -Usage: bun script/triage-unassigned.ts [options] - -Triage open GitHub issues created in the last 30 days with no assignee. - -Options: - -d, --days Look back this many days (default: 30) - -l, --limit Maximum issues to process (default: 200) - --dry-run Print matching issues without running triage - -h, --help Show this help message - -Examples: - bun script/triage-unassigned.ts - bun script/triage-unassigned.ts --limit 3 - bun script/triage-unassigned.ts --dry-run -`) - process.exit(0) - } - - const days = Number(values.days) - const limit = Number(values.limit) - if (!Number.isInteger(days) || days < 1) throw new Error("--days must be a positive integer") - if (!Number.isInteger(limit) || limit < 1) throw new Error("--limit must be a positive integer") - - const created = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) - const query = `no:assignee created:>=${created}` - const issues = JSON.parse( - await text("gh", [ - "issue", - "list", - "--state", - "open", - "--search", - query, - "--limit", - String(limit), - "--json", - "number,title,body", - ]), - ) as Array<{ number: number; title: string; body?: string | null }> - - console.log(`Found ${issues.length} open unassigned issues created since ${created}`) - if (issues.length === 0) return - - if (values["dry-run"]) { - for (const issue of issues) console.log(`#${issue.number} ${issue.title}`) - return - } - - const githubToken = process.env.GITHUB_TOKEN || (await text("gh", ["auth", "token"])).trim() - const failures: Array<{ issue: number; error: string }> = [] - - for (const [index, issue] of issues.entries()) { - console.log(`\n[${index + 1}/${issues.length}] Triaging #${issue.number} ${issue.title}`) - const result = Bun.spawn( - [ - "opencode", - "run", - "--agent", - "triage", - `The following issue was just opened, triage it: - -Issue: #${issue.number} -Title: ${issue.title} - -Body: -${issue.body ?? ""}`, - ], - { - env: { - ...process.env, - GITHUB_TOKEN: githubToken, - ISSUE_NUMBER: String(issue.number), - ISSUE_TITLE: issue.title, - ISSUE_BODY: issue.body ?? "", - }, - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - }, - ) - const status = await result.exited - - if (status === 0) { - console.log(`[${index + 1}/${issues.length}] Done #${issue.number}`) - continue - } - - failures.push({ issue: issue.number, error: `opencode exited with ${status}` }) - console.error(`[${index + 1}/${issues.length}] Failed #${issue.number}: opencode exited with ${status}`) - } - - console.log(`\nFinished triaging ${issues.length - failures.length}/${issues.length} issues`) - if (failures.length === 0) return - - console.error("Failures:") - for (const failure of failures) console.error(`#${failure.issue}: ${failure.error}`) - process.exit(1) -} - -void main() From d1f597b5b5abfe330aa30ca3c33ca043bf9b9a83 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 3 May 2026 17:49:46 +0530 Subject: [PATCH 105/178] fix(vcs): avoid unbounded diff memory usage (#25581) --- packages/opencode/src/git/index.ts | 106 +++++++++- packages/opencode/src/project/vcs.ts | 215 +++++++++++++++------ packages/opencode/test/git/git.test.ts | 47 +++++ packages/opencode/test/project/vcs.test.ts | 29 +++ 4 files changed, 332 insertions(+), 65 deletions(-) diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 16a8624474..fff1d70b2a 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -24,6 +24,7 @@ const fail = (err: unknown) => text: () => "", stdout: Buffer.alloc(0), stderr: Buffer.from(err instanceof Error ? err.message : String(err)), + truncated: false, }) satisfies Result export type Kind = "added" | "deleted" | "modified" @@ -45,16 +46,28 @@ export type Stat = { readonly deletions: number } +export type Patch = { + readonly text: string + readonly truncated: boolean +} + +export interface PatchOptions { + readonly context?: number + readonly maxOutputBytes?: number +} + export interface Result { readonly exitCode: number readonly text: () => string readonly stdout: Buffer readonly stderr: Buffer + readonly truncated: boolean } export interface Options { readonly cwd: string readonly env?: Record + readonly maxOutputBytes?: number } export interface Interface { @@ -68,6 +81,10 @@ export interface Interface { readonly status: (cwd: string) => Effect.Effect readonly diff: (cwd: string, ref: string) => Effect.Effect readonly stats: (cwd: string, ref: string) => Effect.Effect + readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect + readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect + readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect + readonly statUntracked: (cwd: string, file: string) => Effect.Effect } const kind = (code: string): Kind => { @@ -96,15 +113,31 @@ export const layer = Layer.effect( stderr: "pipe", }) const handle = yield* spawner.spawn(proc) - const [stdout, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) + const collect = (stream: typeof handle.stdout) => + Stream.runFold( + stream, + () => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }), + (acc, chunk) => { + if (opts.maxOutputBytes === undefined) { + acc.chunks.push(chunk) + acc.bytes += chunk.length + return acc + } + + const remaining = opts.maxOutputBytes - acc.bytes + if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining)) + acc.bytes += chunk.length + acc.truncated = acc.truncated || acc.bytes > opts.maxOutputBytes + return acc + }, + ).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated }))) + const [stdout, stderr] = yield* Effect.all([collect(handle.stdout), collect(handle.stderr)], { concurrency: 2 }) return { exitCode: yield* handle.exitCode, - text: () => stdout, - stdout: Buffer.from(stdout), - stderr: Buffer.from(stderr), + text: () => stdout.buffer.toString("utf8"), + stdout: stdout.buffer, + stderr: stderr.buffer, + truncated: stdout.truncated || stderr.truncated, } satisfies Result }, Effect.scoped, @@ -240,6 +273,61 @@ export const layer = Layer.effect( }) }) + const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) { + const result = yield* run( + ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.text(), truncated: result.truncated } satisfies Patch + }) + + const patchUntracked = Effect.fn("Git.patchUntracked")(function* ( + cwd: string, + file: string, + options?: PatchOptions, + ) { + const result = yield* run( + [ + "diff", + "--no-index", + "--patch", + "--no-ext-diff", + "--no-renames", + `--unified=${options?.context ?? 3}`, + "--", + "/dev/null", + file, + ], + { cwd, maxOutputBytes: options?.maxOutputBytes }, + ) + return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch + }) + + const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) { + const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], { + cwd, + maxOutputBytes: 4096, + }) + if (result.truncated) return + const parts = result.text().split("\t") + if (parts.length < 2) return + const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10) + const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10) + return { + file, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + } satisfies Stat + }) + return Service.of({ run, branch, @@ -251,6 +339,10 @@ export const layer = Layer.effect( status, diff, stats, + patch, + patchAll, + patchUntracked, + statUntracked, }) }), ) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 24112cf442..28ac143eec 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -1,10 +1,8 @@ import { Effect, Layer, Context, Schema, Stream, Scope } from "effect" import { formatPatch, structuredPatch } from "diff" -import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { InstanceState } from "@/effect/instance-state" -import { AppFileSystem } from "@opencode-ai/core/filesystem" import { FileWatcher } from "@/file/watcher" import { Git } from "@/git" import * as Log from "@opencode-ai/core/util/log" @@ -12,20 +10,11 @@ import { zod } from "@/util/effect-zod" import { NonNegativeInt, withStatics } from "@/util/schema" const log = Log.create({ service: "vcs" }) +const PATCH_CONTEXT_LINES = 2_147_483_647 +const MAX_PATCH_BYTES = 10_000_000 +const MAX_TOTAL_PATCH_BYTES = 10_000_000 -const count = (text: string) => { - if (!text) return 0 - if (!text.endsWith("\n")) return text.split("\n").length - return text.slice(0, -1).split("\n").length -} - -const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) { - const full = path.join(cwd, file) - if (!(yield* fs.exists(full).pipe(Effect.orDie))) return "" - const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array()))) - if (Buffer.from(buf).includes(0)) return "" - return Buffer.from(buf).toString("utf8") -}) +const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 })) const nums = (list: Git.Stat[]) => new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const)) @@ -38,59 +27,168 @@ const merge = (...lists: Git.Item[][]) => { return [...out.values()] } -const files = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, +const emptyBatch = () => ({ patches: new Map(), capped: false }) + +const parseQuotedPath = (value: string) => { + let out = "" + for (let idx = 1; idx < value.length; idx++) { + const char = value[idx] + if (char === '"') return { value: out, end: idx + 1 } + if (char !== "\\") { + out += char + continue + } + + const next = value[++idx] + if (next === "t") out += "\t" + else if (next === "n") out += "\n" + else if (next === "r") out += "\r" + else if (next === '"' || next === "\\") out += next + else out += next ?? "" + } +} + +const parsePathToken = (value: string) => { + if (!value.startsWith('"')) return value.split("\t")[0] + return parseQuotedPath(value)?.value ?? value +} + +const fileFromDiffPath = (value: string | undefined) => { + if (!value || value === "/dev/null") return + const file = parsePathToken(value) + if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2) + return file +} + +const fileFromGitHeader = (header: string) => { + if (header.startsWith('"')) { + const first = parseQuotedPath(header) + const second = first ? header.slice(first.end).trimStart() : undefined + if (!second) return + if (!second.startsWith('"')) return fileFromDiffPath(second) + return fileFromDiffPath(parseQuotedPath(second)?.value) + } + + const separator = header.indexOf(" b/") + if (separator === -1) return + return fileFromDiffPath(header.slice(separator + 1)) +} + +const fileFromPatchChunk = (chunk: string) => { + const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1] + const before = /^--- (.+)$/m.exec(chunk)?.[1] + const file = fileFromDiffPath(next) ?? fileFromDiffPath(before) + if (file) return file + + const header = /^diff --git (.+)$/m.exec(chunk)?.[1] + return fileFromGitHeader(header ?? "") +} + +const splitGitPatch = (patch: Git.Patch) => { + const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index) + const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length)) + if (!patch.truncated) return chunks + return chunks.slice(0, -1) +} + +const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) { + if (list.length === 0) return { patches: new Map(), capped: false } + + const result = yield* git.patchAll(cwd, ref, { + context: PATCH_CONTEXT_LINES, + maxOutputBytes: MAX_TOTAL_PATCH_BYTES, + }) + if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES }) + + return { + patches: splitGitPatch(result).reduce((acc, patch, index) => { + const file = fileFromPatchChunk(patch) ?? list[index]?.file + if (!file) return acc + acc.set(file, (acc.get(file) ?? "") + patch) + return acc + }, new Map()), + capped: result.truncated, + } +}) + +const nativePatch = Effect.fnUntraced(function* ( git: Git.Interface, cwd: string, ref: string | undefined, - list: Git.Item[], - map: Map, + item: Git.Item, ) { - const base = ref ? yield* git.prefix(cwd) : "" - const patch = (file: string, before: string, after: string) => - formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })) - const next = yield* Effect.forEach( - list, - (item) => - Effect.gen(function* () { - const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base) - const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file) - const stat = map.get(item.file) - return { - file: item.file, - patch: patch(item.file, before, after), - additions: stat?.additions ?? (item.status === "added" ? count(after) : 0), - deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0), - status: item.status, - } satisfies FileDiff - }), - { concurrency: 8 }, - ) - return next.toSorted((a, b) => a.file.localeCompare(b.file)) + const result = + item.code === "??" || !ref + ? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) + : yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES }) + if (!result.truncated && result.text) return result.text + + if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES }) + return emptyPatch(item.file) }) -const track = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, +const totalPatch = (file: string, patch: string, total: number) => { + if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false } + log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES }) + return { patch: emptyPatch(file), capped: true } +} + +const patchForItem = Effect.fnUntraced(function* ( git: Git.Interface, cwd: string, ref: string | undefined, + item: Git.Item, + batch: { patches: Map; capped: boolean }, + capped: boolean, ) { - if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map()) - const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 }) - return yield* files(fs, git, cwd, ref, list, nums(stats)) + if (capped) return emptyPatch(item.file) + + const batched = batch.patches.get(item.file) + if (batched !== undefined) return batched + if (item.code !== "??" && batch.capped) return emptyPatch(item.file) + return yield* nativePatch(git, cwd, ref, item) }) -const compare = Effect.fnUntraced(function* ( - fs: AppFileSystem.Interface, +const files = Effect.fnUntraced(function* ( git: Git.Interface, cwd: string, - ref: string, + ref: string | undefined, + list: Git.Item[], + map: Map, + batch: { patches: Map; capped: boolean }, ) { + const next: FileDiff[] = [] + let total = 0 + let capped = false + + for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) { + const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined) + const patch = yield* patchForItem(git, cwd, ref, item, batch, capped) + const result: { patch: string; capped: boolean } = capped + ? { patch, capped: true } + : totalPatch(item.file, patch, total) + capped = capped || result.capped + if (!capped) { + total += Buffer.byteLength(result.patch) + capped = total >= MAX_TOTAL_PATCH_BYTES + } + next.push({ + file: item.file, + patch: result.patch, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + status: item.status, + }) + } + + return next +}) + +const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) { const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], { concurrency: 3, }) return yield* files( - fs, git, cwd, ref, @@ -99,9 +197,15 @@ const compare = Effect.fnUntraced(function* ( extra.filter((item) => item.code === "??"), ), nums(stats), + yield* batchPatches(git, cwd, ref, list), ) }) +const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) { + if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch()) + return yield* diffAgainstRef(git, cwd, ref) +}) + export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) }))) export type Mode = Schema.Schema.Type @@ -147,10 +251,9 @@ interface State { export class Service extends Context.Service()("@opencode/Vcs") {} -export const layer: Layer.Layer = Layer.effect( +export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { - const fs = yield* AppFileSystem.Service const git = yield* Git.Service const bus = yield* Bus.Service const scope = yield* Scope.Scope @@ -204,23 +307,19 @@ export const layer: Layer.Layer { }) }) + test("patch() returns capped native patch output", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "other.txt"), "old\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, weird), "after\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8") + + await withGit(async (rt) => { + const [patch, all, capped] = await Promise.all([ + rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))), + ]) + + expect(patch.truncated).toBe(false) + expect(patch.text).toContain("diff --git") + expect(patch.text).toContain("-before") + expect(patch.text).toContain("+after") + expect(all.truncated).toBe(false) + expect(all.text).toContain("diff --git") + expect(all.text).toContain("other.txt") + expect(all.text).toContain("+new") + expect(capped.truncated).toBe(true) + expect(capped.text).toBe("") + }) + }) + + test("patchUntracked() and statUntracked() handle added files", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8") + + await withGit(async (rt) => { + const [patch, stat] = await Promise.all([ + rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))), + rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))), + ]) + + expect(patch.truncated).toBe(false) + expect(patch.text).toContain("diff --git") + expect(patch.text).toContain("+one") + expect(patch.text).toContain("+two") + expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 })) + }) + }) + test("show() returns empty text for binary blobs", async () => { await using tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3])) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 6fb0e251d3..53ff547ac1 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -234,6 +234,7 @@ describe("Vcs diff", () => { }), ]), ) + expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git") }) }) @@ -259,6 +260,34 @@ describe("Vcs diff", () => { }) }) + test("diff('git') keeps batched patches aligned for type changes", async () => { + if (process.platform === "win32") return + + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8") + await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet() + await fs.unlink(path.join(tmp.path, "a.txt")) + await fs.symlink("target", path.join(tmp.path, "a.txt")) + await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8") + + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const a = diff.find((item) => item.file === "a.txt") + const b = diff.find((item) => item.file === "b.txt") + + expect(a?.patch).toContain("deleted file mode") + expect(a?.patch).toContain("new file mode") + expect(b?.patch).toContain("+new") + }) + }) + test("diff('branch') returns changes against default branch", async () => { await using tmp = await tmpdir({ git: true }) await $`git branch -M main`.cwd(tmp.path).quiet() From ca75ac668103730bab0f0fef382982dd79693c52 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 08:58:34 -0400 Subject: [PATCH 106/178] refactor(server): extract Hono-coupled utilities to backend-neutral modules (#25542) --- packages/opencode/script/httpapi-exercise.ts | 4 +- packages/opencode/src/server/fence.ts | 74 +------------- packages/opencode/src/server/proxy.ts | 2 +- .../routes/instance/httpapi/handlers/tui.ts | 2 +- .../httpapi/middleware/workspace-routing.ts | 8 +- .../server/routes/instance/httpapi/server.ts | 2 +- .../src/server/routes/instance/tui.ts | 32 ++----- packages/opencode/src/server/routes/ui.ts | 96 +------------------ packages/opencode/src/server/shared/fence.ts | 74 ++++++++++++++ .../opencode/src/server/shared/tui-control.ts | 28 ++++++ packages/opencode/src/server/shared/ui.ts | 91 ++++++++++++++++++ .../src/server/shared/workspace-routing.ts | 36 +++++++ packages/opencode/src/server/workspace.ts | 41 +------- .../opencode/test/server/httpapi-ui.test.ts | 2 +- .../test/server/workspace-routing.test.ts | 6 +- 15 files changed, 265 insertions(+), 233 deletions(-) create mode 100644 packages/opencode/src/server/shared/fence.ts create mode 100644 packages/opencode/src/server/shared/tui-control.ts create mode 100644 packages/opencode/src/server/shared/ui.ts create mode 100644 packages/opencode/src/server/shared/workspace-routing.ts diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 1681f2e212..5bfcae14eb 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -182,7 +182,7 @@ type Runtime = { Todo: (typeof import("../src/session/todo"))["Todo"] Worktree: (typeof import("../src/worktree"))["Worktree"] Project: (typeof import("../src/project/project"))["Project"] - Tui: typeof import("../src/server/routes/instance/tui") + Tui: typeof import("../src/server/shared/tui-control") disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"] tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"] resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"] @@ -203,7 +203,7 @@ function runtime() { const todo = await import("../src/session/todo") const worktree = await import("../src/worktree") const project = await import("../src/project/project") - const tui = await import("../src/server/routes/instance/tui") + const tui = await import("../src/server/shared/tui-control") const fixture = await import("../test/fixture/fixture") const db = await import("../test/fixture/db") return { diff --git a/packages/opencode/src/server/fence.ts b/packages/opencode/src/server/fence.ts index aa784c90df..1b8c42c899 100644 --- a/packages/opencode/src/server/fence.ts +++ b/packages/opencode/src/server/fence.ts @@ -1,78 +1,8 @@ import type { MiddlewareHandler } from "hono" -import { Database } from "@/storage/db" -import { inArray } from "drizzle-orm" -import { EventSequenceTable } from "@/sync/event.sql" -import { Workspace } from "@/control-plane/workspace" -import type { WorkspaceID } from "@/control-plane/schema" import * as Log from "@opencode-ai/core/util/log" -import { AppRuntime } from "@/effect/app-runtime" -import { Effect } from "effect" +import { HEADER, diff, load } from "./shared/fence" -const HEADER = "x-opencode-sync" -type State = Record -const log = Log.create({ service: "fence" }) - -export function load(ids?: string[]) { - const rows = Database.use((db) => { - if (!ids?.length) { - return db.select().from(EventSequenceTable).all() - } - - return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() - }) - - return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State -} - -export function diff(prev: State, next: State) { - const ids = new Set([...Object.keys(prev), ...Object.keys(next)]) - return Object.fromEntries( - [...ids] - .map((id) => [id, next[id] ?? -1] as const) - .filter(([id, seq]) => { - return (prev[id] ?? -1) !== seq - }), - ) as State -} - -export function parse(headers: Headers) { - const raw = headers.get(HEADER) - if (!raw) return - - let data - - try { - data = JSON.parse(raw) - } catch { - return - } - - if (!data || typeof data !== "object") return - - return Object.fromEntries( - Object.entries(data).filter(([id, seq]) => { - return typeof id === "string" && Number.isInteger(seq) - }), - ) as State -} - -export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { - return Effect.gen(function* () { - log.info("waiting for state", { - workspaceID, - state, - }) - yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)) - log.info("state fully synced", { - workspaceID, - state, - }) - }) -} - -export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { - await AppRuntime.runPromise(waitEffect(workspaceID, state, signal)) -} +const log = Log.create({ service: "fence-middleware" }) export const FenceMiddleware: MiddlewareHandler = async (c, next) => { if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next() diff --git a/packages/opencode/src/server/proxy.ts b/packages/opencode/src/server/proxy.ts index 051d64c24d..069f308512 100644 --- a/packages/opencode/src/server/proxy.ts +++ b/packages/opencode/src/server/proxy.ts @@ -1,7 +1,7 @@ import { Hono } from "hono" import type { UpgradeWebSocket } from "hono/ws" import * as Log from "@opencode-ai/core/util/log" -import * as Fence from "./fence" +import * as Fence from "./shared/fence" import type { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts index c7c447ce85..cc85321685 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/tui.ts @@ -5,7 +5,7 @@ import * as Database from "@/storage/db" import { eq } from "drizzle-orm" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" -import { nextTuiRequest, submitTuiResponse } from "../../tui" +import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control" import { InstanceHttpApi } from "../api" import { CommandPayload, TuiPublishPayload } from "../groups/tui" diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 4a07aaf11c..caa520f7ca 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -5,8 +5,12 @@ import { Workspace } from "@/control-plane/workspace" import { EffectBridge } from "@/effect/bridge" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" -import * as Fence from "@/server/fence" -import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace" +import * as Fence from "@/server/shared/fence" +import { + getWorkspaceRouteSessionID, + isLocalWorkspaceRoute, + workspaceProxyURL, +} from "@/server/shared/workspace-routing" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index e53eca3eff..650efe2b0d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -45,7 +45,7 @@ import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" -import { serveUIEffect } from "@/server/routes/ui" +import { serveUIEffect } from "@/server/shared/ui" import { InstanceHttpApi, RootHttpApi } from "./api" import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { EventApi, eventHandlers } from "./event" diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index d2be015211..a7a0c9cbdc 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -7,32 +7,16 @@ import { Session } from "@/session/session" import type { SessionID } from "@/session/schema" import { TuiEvent } from "@/cli/cmd/tui/event" import { zodObject } from "@/util/effect-zod" -import { AsyncQueue } from "@/util/queue" import { errors } from "../../error" import { lazy } from "@/util/lazy" import { runRequest } from "./trace" - -export const TuiRequest = z.object({ - path: z.string(), - body: z.any(), -}) - -export type TuiRequest = z.infer - -const request = new AsyncQueue() -const response = new AsyncQueue() - -export function nextTuiRequest() { - return request.next() -} - -export function submitTuiRequest(body: TuiRequest) { - request.push(body) -} - -export function submitTuiResponse(body: unknown) { - response.push(body) -} +import { + TuiRequest, + nextTuiRequest, + nextTuiResponse, + submitTuiRequest, + submitTuiResponse, +} from "@/server/shared/tui-control" export async function callTui(ctx: Context) { const body = await ctx.req.json() @@ -40,7 +24,7 @@ export async function callTui(ctx: Context) { path: ctx.req.path, body, }) - return response.next() + return nextTuiResponse() } const TuiControlRoutes = new Hono() diff --git a/packages/opencode/src/server/routes/ui.ts b/packages/opencode/src/server/routes/ui.ts index 403d85d66c..ce06b2b35e 100644 --- a/packages/opencode/src/server/routes/ui.ts +++ b/packages/opencode/src/server/routes/ui.ts @@ -1,53 +1,10 @@ -import { Flag } from "@opencode-ai/core/flag/flag" +import fs from "node:fs/promises" +import { createHash } from "node:crypto" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { Effect, Stream } from "effect" -import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { Hono } from "hono" import { proxy } from "hono/proxy" -import { getMimeType } from "hono/utils/mime" -import { createHash } from "node:crypto" -import fs from "node:fs/promises" import { ProxyUtil } from "../proxy-util" - -const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI - ? Promise.resolve(null) - : // @ts-expect-error - generated file at build time - import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) - -const DEFAULT_CSP = - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" -const UI_UPSTREAM = new URL("https://app.opencode.ai") - -const csp = (hash = "") => - `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` - -function themePreloadHash(body: string) { - return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) -} - -function requestBody(request: HttpServerRequest.HttpServerRequest) { - if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty - const len = request.headers["content-length"] - return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len)) -} - -function proxyResponseHeaders(headers: Record) { - const result = new Headers(headers) - // FetchHttpClient exposes decoded response bodies, so forwarding upstream - // transfer metadata makes browsers decode already-decoded assets again. - result.delete("content-encoding") - result.delete("content-length") - return result -} - -function upstreamURL(path: string) { - return new URL(path, UI_UPSTREAM).toString() -} - -function embeddedUI() { - if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null) - return embeddedUIPromise -} +import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui" export async function serveUI(request: Request) { const embeddedWebUI = await embeddedUI() @@ -58,7 +15,7 @@ export async function serveUI(request: Request) { if (!match) return Response.json({ error: "Not Found" }, { status: 404 }) if (await fs.exists(match)) { - const mime = getMimeType(match) ?? "text/plain" + const mime = AppFileSystem.mimeType(match) const headers = new Headers({ "content-type": mime }) if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) return new Response(new Uint8Array(await fs.readFile(match)), { headers }) @@ -79,49 +36,4 @@ export async function serveUI(request: Request) { return response } -export function serveUIEffect( - request: HttpServerRequest.HttpServerRequest, - services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient }, -) { - return Effect.gen(function* () { - const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) - const path = new URL(request.url, "http://localhost").pathname - - if (embeddedWebUI) { - const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - - if (yield* services.fs.existsSafe(match)) { - const mime = getMimeType(match) ?? "text/plain" - const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) - return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers }) - } - - return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - } - - const response = yield* services.client.execute( - HttpClientRequest.make(request.method)(upstreamURL(path), { - headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), - body: requestBody(request), - }), - ) - const headers = proxyResponseHeaders(response.headers) - - if (response.headers["content-type"]?.includes("text/html")) { - const body = yield* response.text - const match = themePreloadHash(body) - headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")) - return HttpServerResponse.text(body, { status: response.status, headers }) - } - - headers.set("Content-Security-Policy", csp()) - return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { - status: response.status, - headers, - }) - }) -} - export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw)) diff --git a/packages/opencode/src/server/shared/fence.ts b/packages/opencode/src/server/shared/fence.ts new file mode 100644 index 0000000000..659764970b --- /dev/null +++ b/packages/opencode/src/server/shared/fence.ts @@ -0,0 +1,74 @@ +import { Database } from "@/storage/db" +import { inArray } from "drizzle-orm" +import { EventSequenceTable } from "@/sync/event.sql" +import { Workspace } from "@/control-plane/workspace" +import type { WorkspaceID } from "@/control-plane/schema" +import * as Log from "@opencode-ai/core/util/log" +import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" + +export const HEADER = "x-opencode-sync" +export type State = Record +const log = Log.create({ service: "fence" }) + +export function load(ids?: string[]) { + const rows = Database.use((db) => { + if (!ids?.length) { + return db.select().from(EventSequenceTable).all() + } + + return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all() + }) + + return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State +} + +export function diff(prev: State, next: State) { + const ids = new Set([...Object.keys(prev), ...Object.keys(next)]) + return Object.fromEntries( + [...ids] + .map((id) => [id, next[id] ?? -1] as const) + .filter(([id, seq]) => { + return (prev[id] ?? -1) !== seq + }), + ) as State +} + +export function parse(headers: Headers) { + const raw = headers.get(HEADER) + if (!raw) return + + let data + + try { + data = JSON.parse(raw) + } catch { + return + } + + if (!data || typeof data !== "object") return + + return Object.fromEntries( + Object.entries(data).filter(([id, seq]) => { + return typeof id === "string" && Number.isInteger(seq) + }), + ) as State +} + +export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { + return Effect.gen(function* () { + log.info("waiting for state", { + workspaceID, + state, + }) + yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal)) + log.info("state fully synced", { + workspaceID, + state, + }) + }) +} + +export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) { + await AppRuntime.runPromise(waitEffect(workspaceID, state, signal)) +} diff --git a/packages/opencode/src/server/shared/tui-control.ts b/packages/opencode/src/server/shared/tui-control.ts new file mode 100644 index 0000000000..40aaf04a96 --- /dev/null +++ b/packages/opencode/src/server/shared/tui-control.ts @@ -0,0 +1,28 @@ +import z from "zod" +import { AsyncQueue } from "@/util/queue" + +export const TuiRequest = z.object({ + path: z.string(), + body: z.any(), +}) + +export type TuiRequest = z.infer + +const request = new AsyncQueue() +const response = new AsyncQueue() + +export function nextTuiRequest() { + return request.next() +} + +export function submitTuiRequest(body: TuiRequest) { + request.push(body) +} + +export function submitTuiResponse(body: unknown) { + response.push(body) +} + +export function nextTuiResponse() { + return response.next() +} diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts new file mode 100644 index 0000000000..db67749e08 --- /dev/null +++ b/packages/opencode/src/server/shared/ui.ts @@ -0,0 +1,91 @@ +import { Flag } from "@opencode-ai/core/flag/flag" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Effect, Stream } from "effect" +import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { createHash } from "node:crypto" +import { ProxyUtil } from "../proxy-util" + +const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI + ? Promise.resolve(null) + : // @ts-expect-error - generated file at build time + import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null) + +export const DEFAULT_CSP = + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:" +export const UI_UPSTREAM = new URL("https://app.opencode.ai") + +export const csp = (hash = "") => + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` + +export function themePreloadHash(body: string) { + return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) +} + +function requestBody(request: HttpServerRequest.HttpServerRequest) { + if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty + const len = request.headers["content-length"] + return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len)) +} + +function proxyResponseHeaders(headers: Record) { + const result = new Headers(headers) + // FetchHttpClient exposes decoded response bodies, so forwarding upstream + // transfer metadata makes browsers decode already-decoded assets again. + result.delete("content-encoding") + result.delete("content-length") + return result +} + +export function upstreamURL(path: string) { + return new URL(path, UI_UPSTREAM).toString() +} + +export function embeddedUI() { + if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null) + return embeddedUIPromise +} + +export function serveUIEffect( + request: HttpServerRequest.HttpServerRequest, + services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient }, +) { + return Effect.gen(function* () { + const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) + const path = new URL(request.url, "http://localhost").pathname + + if (embeddedWebUI) { + const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null + if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) + + if (yield* services.fs.existsSafe(match)) { + const mime = AppFileSystem.mimeType(match) + const headers = new Headers({ "content-type": mime }) + if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers }) + } + + return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) + } + + const response = yield* services.client.execute( + HttpClientRequest.make(request.method)(upstreamURL(path), { + headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }), + body: requestBody(request), + }), + ) + const headers = proxyResponseHeaders(response.headers) + + if (response.headers["content-type"]?.includes("text/html")) { + const body = yield* response.text + const match = themePreloadHash(body) + headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")) + return HttpServerResponse.text(body, { status: response.status, headers }) + } + + headers.set("Content-Security-Policy", csp()) + return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { + status: response.status, + headers, + }) + }) +} diff --git a/packages/opencode/src/server/shared/workspace-routing.ts b/packages/opencode/src/server/shared/workspace-routing.ts new file mode 100644 index 0000000000..366c455dd6 --- /dev/null +++ b/packages/opencode/src/server/shared/workspace-routing.ts @@ -0,0 +1,36 @@ +import { SessionID } from "@/session/schema" + +type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } + +const RULES: Array = [ + { path: "/experimental/workspace", action: "local" }, + { path: "/session/status", action: "forward" }, + { method: "GET", path: "/session", action: "local" }, +] + +export function isLocalWorkspaceRoute(method: string, path: string) { + for (const rule of RULES) { + if (rule.method && rule.method !== method) continue + const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") + if (match) return rule.action === "local" + } + return false +} + +export function getWorkspaceRouteSessionID(url: URL) { + if (url.pathname === "/session/status") return null + + const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] + if (!id) return null + + return SessionID.make(id) +} + +export function workspaceProxyURL(target: string | URL, requestURL: URL) { + const proxyURL = new URL(target) + proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}` + proxyURL.search = requestURL.search + proxyURL.hash = requestURL.hash + proxyURL.searchParams.delete("workspace") + return proxyURL +} diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index f5f667222f..6d4cae807c 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -8,45 +8,14 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { AppRuntime } from "@/effect/app-runtime" import { WithInstance } from "@/project/with-instance" import { Session } from "@/session/session" -import { SessionID } from "@/session/schema" import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import { ServerProxy } from "./proxy" - -type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" } - -const RULES: Array = [ - { path: "/experimental/workspace", action: "local" }, - { path: "/session/status", action: "forward" }, - { method: "GET", path: "/session", action: "local" }, -] - -export function isLocalWorkspaceRoute(method: string, path: string) { - for (const rule of RULES) { - if (rule.method && rule.method !== method) continue - const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/") - if (match) return rule.action === "local" - } - return false -} - -export function getWorkspaceRouteSessionID(url: URL) { - if (url.pathname === "/session/status") return null - - const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1] - if (!id) return null - - return SessionID.make(id) -} - -export function workspaceProxyURL(target: string | URL, requestURL: URL) { - const proxyURL = new URL(target) - proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}` - proxyURL.search = requestURL.search - proxyURL.hash = requestURL.hash - proxyURL.searchParams.delete("workspace") - return proxyURL -} +import { + getWorkspaceRouteSessionID, + isLocalWorkspaceRoute, + workspaceProxyURL, +} from "./shared/workspace-routing" async function getSessionWorkspace(url: URL) { const id = getWorkspaceRouteSessionID(url) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 7c9739f51d..09b234bde9 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -17,7 +17,7 @@ import { authorizationRouterMiddleware, } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { serveUIEffect } from "../../src/server/routes/ui" +import { serveUIEffect } from "../../src/server/shared/ui" import { Server } from "../../src/server/server" void Log.init({ print: false }) diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts index 22c44a6dff..a921ae2774 100644 --- a/packages/opencode/test/server/workspace-routing.test.ts +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test" -import { isLocalWorkspaceRoute, getWorkspaceRouteSessionID, workspaceProxyURL } from "../../src/server/workspace" +import { + isLocalWorkspaceRoute, + getWorkspaceRouteSessionID, + workspaceProxyURL, +} from "../../src/server/shared/workspace-routing" import { SessionID } from "../../src/session/schema" describe("isLocalWorkspaceRoute", () => { From 3c9f3c5786f524d0861f4113be7d2cfa75db3a74 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 12:59:40 +0000 Subject: [PATCH 107/178] chore: generate --- .../routes/instance/httpapi/middleware/workspace-routing.ts | 6 +----- packages/opencode/src/server/workspace.ts | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index caa520f7ca..a91a9992df 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -6,11 +6,7 @@ import { EffectBridge } from "@/effect/bridge" import { Session } from "@/session/session" import { HttpApiProxy } from "./proxy" import * as Fence from "@/server/shared/fence" -import { - getWorkspaceRouteSessionID, - isLocalWorkspaceRoute, - workspaceProxyURL, -} from "@/server/shared/workspace-routing" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index 6d4cae807c..0972875305 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -11,11 +11,7 @@ import { Session } from "@/session/session" import { Effect } from "effect" import * as Log from "@opencode-ai/core/util/log" import { ServerProxy } from "./proxy" -import { - getWorkspaceRouteSessionID, - isLocalWorkspaceRoute, - workspaceProxyURL, -} from "./shared/workspace-routing" +import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing" async function getSessionWorkspace(url: URL) { const id = getWorkspaceRouteSessionID(url) From 0ee3b872896085230049cc7eeeaee7eabfc644fb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 09:06:23 -0400 Subject: [PATCH 108/178] feat(server): Server.openapi() backed by HttpApi spec, parity-checked against Hono output (#25545) --- packages/opencode/script/httpapi-exercise.ts | 2 +- packages/opencode/src/cli/cmd/generate.ts | 22 ++++++++++------ packages/opencode/src/server/server.ts | 25 +++++++++++++++++++ .../test/server/httpapi-bridge.test.ts | 6 ++--- .../opencode/test/server/httpapi-tui.test.ts | 2 +- packages/sdk/js/script/build.ts | 6 +++-- 6 files changed, 48 insertions(+), 15 deletions(-) diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 5bfcae14eb..9755cf4017 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -1506,7 +1506,7 @@ const main = Effect.gen(function* () { const options = parseOptions(Bun.argv.slice(2)) const modules = yield* Effect.promise(() => runtime()) const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) - const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapi())) + const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono())) const selected = scenarios.filter((scenario) => matches(options, scenario)) const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario))) const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario))) diff --git a/packages/opencode/src/cli/cmd/generate.ts b/packages/opencode/src/cli/cmd/generate.ts index 768002957d..cb15b484e3 100644 --- a/packages/opencode/src/cli/cmd/generate.ts +++ b/packages/opencode/src/cli/cmd/generate.ts @@ -1,22 +1,28 @@ import { Server } from "../../server/server" -import { PublicApi } from "../../server/routes/instance/httpapi/public" import type { CommandModule } from "yargs" -import { OpenApi } from "effect/unstable/httpapi" type Args = { httpapi: boolean + hono: boolean } export const GenerateCommand = { command: "generate", builder: (yargs) => - yargs.option("httpapi", { - type: "boolean", - default: false, - description: "Generate OpenAPI from the experimental Effect HttpApi contract", - }), + yargs + .option("httpapi", { + type: "boolean", + default: false, + description: + "Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)", + }) + .option("hono", { + type: "boolean", + default: false, + description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)", + }), handler: async (args) => { - const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi() + const specs = args.hono ? await Server.openapiHono() : await Server.openapi() for (const item of Object.values(specs.paths)) { for (const method of ["get", "post", "put", "delete", "patch"] as const) { const operation = item[method] diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6ebc8dc487..13ec706163 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,6 +5,7 @@ import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { WorkspaceID } from "@/control-plane/schema" +import { OpenApi } from "effect/unstable/httpapi" import { MDNS } from "./mdns" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { FenceMiddleware } from "./fence" @@ -17,6 +18,7 @@ import { WorkspaceRouterMiddleware } from "./workspace" import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" +import { PublicApi } from "./routes/instance/httpapi/public" import * as ServerBackend from "./backend" import type { CorsOptions } from "./cors" @@ -135,7 +137,30 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv } } +/** + * Generate the OpenAPI document used by the SDK build. + * + * Since the Effect HttpApi backend now covers every Hono route (plus the new + * `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity + * audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`. + * `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi` + * transform that injects instance query parameters, strips Effect's optional + * null arms, normalizes component names, and patches SSE response schemas so + * the generated SDK keeps the legacy Hono shape. + * + * The Hono-derived spec is still reachable via `openapiHono()` so reviewers + * can diff the two outputs while the Hono backend lingers; once the Hono + * backend is deleted that helper goes with it. + */ export async function openapi() { + return OpenApi.fromApi(PublicApi) +} + +/** + * Hono-derived OpenAPI spec, retained for parity diffing only. Delete once + * the Hono backend is removed. + */ +export async function openapiHono() { // Build a fresh app with all routes registered directly so // hono-openapi can see describeRoute metadata (`.route()` wraps // handlers when the sub-app has a custom errorHandler, which diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index b7ffa0ca5e..615899f2b4 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -222,7 +222,7 @@ describe("HttpApi server", () => { }) test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => { - const honoRoutes = openApiRouteKeys(await Server.openapi()) + const honoRoutes = openApiRouteKeys(await Server.openapiHono()) const effectRoutes = openApiRouteKeys(effectOpenApi()) expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([]) @@ -237,7 +237,7 @@ describe("HttpApi server", () => { }) test("matches generated OpenAPI route parameters", async () => { - const hono = openApiParameters(await Server.openapi()) + const hono = openApiParameters(await Server.openapiHono()) const effect = openApiParameters(effectOpenApi()) expect( @@ -248,7 +248,7 @@ describe("HttpApi server", () => { }) test("matches generated OpenAPI request body shape", async () => { - const hono = openApiRequestBodies(await Server.openapi()) + const hono = openApiRequestBodies(await Server.openapiHono()) const effect = openApiRequestBodies(effectOpenApi()) expect( diff --git a/packages/opencode/test/server/httpapi-tui.test.ts b/packages/opencode/test/server/httpapi-tui.test.ts index 1b9e1c1503..8d2670c492 100644 --- a/packages/opencode/test/server/httpapi-tui.test.ts +++ b/packages/opencode/test/server/httpapi-tui.test.ts @@ -46,7 +46,7 @@ afterEach(async () => { describe("tui HttpApi bridge", () => { test("documents legacy bad request responses", async () => { - const legacy = await Server.openapi() + const legacy = await Server.openapiHono() const effect = OpenApi.fromApi(TuiApi) for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) { expect(legacy.paths[path].post?.responses?.[400]).toBeDefined() diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index c490a0be70..946ad1402b 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -12,10 +12,12 @@ import { createClient } from "@hey-api/openapi-ts" const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi" const opencode = path.resolve(dir, "../../opencode") +// `bun dev generate` now derives the spec from the Effect HttpApi contract by +// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs. if (openapiSource === "httpapi") { - await $`bun dev generate --httpapi > ${dir}/openapi.json`.cwd(opencode) -} else { await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) +} else { + await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode) } await createClient({ From a43f767abbc8b6244142eb62e66a26ba7ec784bd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 13:07:30 +0000 Subject: [PATCH 109/178] chore: generate --- packages/sdk/openapi.json | 19552 +++++++++++++++++++----------------- 1 file changed, 10539 insertions(+), 9013 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b1c4ec1d76..df00c17266 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1,16 +1,201 @@ { - "openapi": "3.1.1", + "openapi": "3.1.0", "info": { "title": "opencode", - "description": "opencode api", - "version": "1.0.0" + "version": "1.0.0", + "description": "opencode api" }, "paths": { + "/auth/{providerID}": { + "put": { + "tags": ["control"], + "operationId": "auth.set", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully set authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully set authentication credentials" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Set authentication credentials", + "summary": "Set auth credentials", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Auth" + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["control"], + "operationId": "auth.remove", + "parameters": [ + { + "name": "providerID", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Successfully removed authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully removed authentication credentials" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Remove authentication credentials", + "summary": "Remove auth credentials", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" + } + ] + } + }, + "/log": { + "post": { + "tags": ["control"], + "operationId": "app.log", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Log entry written successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Log entry written successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Write a log entry to the server logs with specified level and metadata.", + "summary": "Write log", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "service": { + "type": "string", + "description": "Service name for the log entry" + }, + "level": { + "type": "string", + "enum": ["debug", "info", "error", "warn"], + "description": "Log level" + }, + "message": { + "type": "string", + "description": "Log message" + }, + "extra": { + "type": "object" + } + }, + "required": ["service", "level", "message"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" + } + ] + } + }, "/global/health": { "get": { + "tags": ["global"], "operationId": "global.health", - "summary": "Get health", - "description": "Get health information about the OpenCode server.", + "parameters": [], "responses": { "200": { "description": "Health information", @@ -21,18 +206,22 @@ "properties": { "healthy": { "type": "boolean", - "const": true + "enum": [true] }, "version": { "type": "string" } }, - "required": ["healthy", "version"] + "required": ["healthy", "version"], + "additionalProperties": false, + "description": "Health information" } } } } }, + "description": "Get health information about the OpenCode server.", + "summary": "Get health", "x-codeSamples": [ { "lang": "js", @@ -43,9 +232,9 @@ }, "/global/event": { "get": { + "tags": ["global"], "operationId": "global.event", - "summary": "Get global events", - "description": "Subscribe to global events from the OpenCode system using server-sent events.", + "parameters": [], "responses": { "200": { "description": "Event stream", @@ -58,6 +247,8 @@ } } }, + "description": "Subscribe to global events from the OpenCode system using server-sent events.", + "summary": "Get global events", "x-codeSamples": [ { "lang": "js", @@ -68,9 +259,9 @@ }, "/global/config": { "get": { + "tags": ["global"], "operationId": "global.config.get", - "summary": "Get global configuration", - "description": "Retrieve the current global OpenCode configuration settings and preferences.", + "parameters": [], "responses": { "200": { "description": "Get global config info", @@ -83,6 +274,8 @@ } } }, + "description": "Retrieve the current global OpenCode configuration settings and preferences.", + "summary": "Get global configuration", "x-codeSamples": [ { "lang": "js", @@ -91,9 +284,9 @@ ] }, "patch": { + "tags": ["global"], "operationId": "global.config.update", - "summary": "Update global configuration", - "description": "Update global OpenCode configuration settings and preferences.", + "parameters": [], "responses": { "200": { "description": "Successfully updated global config", @@ -116,6 +309,8 @@ } } }, + "description": "Update global OpenCode configuration settings and preferences.", + "summary": "Update global configuration", "requestBody": { "content": { "application/json": { @@ -135,21 +330,24 @@ }, "/global/dispose": { "post": { + "tags": ["global"], "operationId": "global.dispose", - "summary": "Dispose instance", - "description": "Clean up and dispose all OpenCode instances, releasing all resources.", + "parameters": [], "responses": { "200": { "description": "Global disposed", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Global disposed" } } } } }, + "description": "Clean up and dispose all OpenCode instances, releasing all resources.", + "summary": "Dispose instance", "x-codeSamples": [ { "lang": "js", @@ -160,9 +358,9 @@ }, "/global/upgrade": { "post": { + "tags": ["global"], "operationId": "global.upgrade", - "summary": "Upgrade opencode", - "description": "Upgrade opencode to the specified version or latest if not specified.", + "parameters": [], "responses": { "200": { "description": "Upgrade result", @@ -175,28 +373,31 @@ "properties": { "success": { "type": "boolean", - "const": true + "enum": [true] }, "version": { "type": "string" } }, - "required": ["success", "version"] + "required": ["success", "version"], + "additionalProperties": false }, { "type": "object", "properties": { "success": { "type": "boolean", - "const": false + "enum": [false] }, "error": { "type": "string" } }, - "required": ["success", "error"] + "required": ["success", "error"], + "additionalProperties": false } - ] + ], + "description": "Upgrade result" } } } @@ -212,6 +413,8 @@ } } }, + "description": "Upgrade opencode to the specified version or latest if not specified.", + "summary": "Upgrade opencode", "requestBody": { "content": { "application/json": { @@ -221,7 +424,8 @@ "target": { "type": "string" } - } + }, + "additionalProperties": false } } } @@ -234,131 +438,121 @@ ] } }, - "/auth/{providerID}": { - "put": { - "operationId": "auth.set", - "summary": "Set auth credentials", - "description": "Set authentication credentials", - "responses": { - "200": { - "description": "Successfully set authentication credentials", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } + "/event": { + "get": { + "tags": ["event"], + "operationId": "event.subscribe", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "parameters": [ { - "in": "path", - "name": "providerID", + "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" - }, - "required": true + } } ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Auth" + "responses": { + "200": { + "description": "Event stream", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/Event" + } } } } }, + "description": "Get events", + "summary": "Subscribe to events", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" } ] - }, - "delete": { - "operationId": "auth.remove", - "summary": "Remove auth credentials", - "description": "Remove authentication credentials", - "responses": { - "200": { - "description": "Successfully removed authentication credentials", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } + } + }, + "/config": { + "get": { + "tags": ["config"], + "operationId": "config.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" } }, - "400": { - "description": "Bad request", + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Get config info", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/Config" } } } } }, - "parameters": [ - { - "in": "path", - "name": "providerID", - "schema": { - "type": "string" - }, - "required": true - } - ], + "description": "Retrieve the current OpenCode configuration settings and preferences.", + "summary": "Get configuration", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.get({\n ...\n})" } ] - } - }, - "/log": { - "post": { - "operationId": "app.log", + }, + "patch": { + "tags": ["config"], + "operationId": "config.update", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Write log", - "description": "Write a log entry to the server logs with specified level and metadata.", "responses": { "200": { - "description": "Log entry written successfully", + "description": "Successfully updated config", "content": { "application/json": { "schema": { - "type": "boolean" + "$ref": "#/components/schemas/Config" } } } @@ -374,35 +568,13 @@ } } }, + "description": "Update OpenCode configuration settings and preferences.", + "summary": "Update configuration", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "service": { - "description": "Service name for the log entry", - "type": "string" - }, - "level": { - "description": "Log level", - "type": "string", - "enum": ["debug", "info", "error", "warn"] - }, - "message": { - "description": "Log message", - "type": "string" - }, - "extra": { - "description": "Additional metadata for the log entry", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["service", "level", "message"] + "$ref": "#/components/schemas/Config" } } } @@ -410,289 +582,302 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.log({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.update({\n ...\n})" } ] } }, - "/experimental/workspace/adapter": { + "/config/providers": { "get": { - "operationId": "experimental.workspace.adapter.list", + "tags": ["config"], + "operationId": "config.providers", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List workspace adapters", - "description": "List all available workspace adapters for the current project.", "responses": { "200": { - "description": "Workspace adapters", + "description": "List of providers", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Provider" } }, - "required": ["type", "name", "description"] - } + "default": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["providers", "default"], + "additionalProperties": false, + "description": "List of providers" } } } } }, + "description": "Get a list of all configured AI providers and their default models.", + "summary": "List config providers", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.providers({\n ...\n})" } ] } }, - "/experimental/workspace": { - "post": { - "operationId": "experimental.workspace.create", + "/experimental/console": { + "get": { + "tags": ["experimental"], + "operationId": "experimental.console.get", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Create workspace", - "description": "Create a workspace for the current project.", "responses": { "200": { - "description": "Workspace created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Workspace" - } - } - } - }, - "400": { - "description": "Bad request", + "description": "Active Console provider metadata", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/ConsoleState" } } } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { - "type": "string" - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - } - }, - "required": ["type", "branch", "extra"] - } - } - } - }, + "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", + "summary": "Get active Console provider metadata", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.get({\n ...\n})" } ] - }, + } + }, + "/experimental/console/orgs": { "get": { - "operationId": "experimental.workspace.list", + "tags": ["experimental"], + "operationId": "experimental.console.listOrgs", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List workspaces", - "description": "List all workspaces.", "responses": { "200": { - "description": "Workspaces", + "description": "Switchable Console orgs", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Workspace" - } + "type": "object", + "properties": { + "orgs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "accountID": { + "type": "string" + }, + "accountEmail": { + "type": "string" + }, + "accountUrl": { + "type": "string" + }, + "orgID": { + "type": "string" + }, + "orgName": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"], + "additionalProperties": false + } + } + }, + "required": ["orgs"], + "additionalProperties": false, + "description": "Switchable Console orgs" } } } } }, + "description": "Get the available Console orgs across logged-in accounts, including the current active org.", + "summary": "List switchable Console orgs", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.listOrgs({\n ...\n})" } ] } }, - "/experimental/workspace/status": { - "get": { - "operationId": "experimental.workspace.status", - "parameters": [ - { - "in": "query", + "/experimental/console/switch": { + "post": { + "tags": ["experimental"], + "operationId": "experimental.console.switchOrg", + "parameters": [ + { "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Workspace status", - "description": "Get connection status for workspaces in the current project.", "responses": { "200": { - "description": "Workspace status", + "description": "Switch success", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } + "type": "boolean", + "description": "Switch success" } } } } }, + "description": "Persist a new active Console account/org selection for the current local OpenCode state.", + "summary": "Switch active Console org", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "accountID": { + "type": "string" + }, + "orgID": { + "type": "string" + } + }, + "required": ["accountID", "orgID"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.switchOrg({\n ...\n})" } ] } }, - "/experimental/workspace/{id}": { - "delete": { - "operationId": "experimental.workspace.remove", + "/experimental/tool": { + "get": { + "tags": ["experimental"], + "operationId": "tool.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", - "name": "id", + "name": "provider", + "in": "query", "schema": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" + }, + "required": true + }, + { + "name": "model", + "in": "query", + "schema": { + "type": "string" }, "required": true } ], - "summary": "Remove workspace", - "description": "Remove an existing workspace.", "responses": { "200": { - "description": "Workspace removed", + "description": "Tools", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Workspace" + "$ref": "#/components/schemas/ToolList" } } } @@ -708,59 +893,45 @@ } } }, + "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", + "summary": "List tools", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.list({\n ...\n})" } ] } }, - "/experimental/workspace/{id}/session-restore": { - "post": { - "operationId": "experimental.workspace.sessionRestore", + "/experimental/tool/ids": { + "get": { + "tags": ["experimental"], + "operationId": "tool.ids", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true } ], - "summary": "Restore session into workspace", - "description": "Replay a session's sync events into the target workspace in batches.", "responses": { "200": { - "description": "Session replay started", + "description": "Tool IDs", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["total"] + "$ref": "#/components/schemas/ToolIDs" } } } @@ -776,192 +947,217 @@ } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - } - } - }, + "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", + "summary": "List tool IDs", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" } ] } }, - "/project": { + "/experimental/worktree": { "get": { - "operationId": "project.list", + "tags": ["experimental"], + "operationId": "worktree.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List all projects", - "description": "Get a list of projects that have been opened with OpenCode.", "responses": { "200": { - "description": "List of projects", + "description": "List of worktree directories", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Project" - } + "type": "string" + }, + "description": "List of worktree directories" } } } } }, + "description": "List all sandbox worktrees for the current project.", + "summary": "List worktrees", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})" } ] - } - }, - "/project/current": { - "get": { - "operationId": "project.current", + }, + "post": { + "tags": ["experimental"], + "operationId": "worktree.create", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get current project", - "description": "Retrieve the currently active project that OpenCode is working with.", "responses": { "200": { - "description": "Current project information", + "description": "Worktree created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Project" + "$ref": "#/components/schemas/Worktree" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" } } } } }, + "description": "Create a new git worktree for the current project and run any configured startup scripts.", + "summary": "Create worktree", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorktreeCreateInput" + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.create({\n ...\n})" } ] - } - }, - "/project/git/init": { - "post": { - "operationId": "project.initGit", + }, + "delete": { + "tags": ["experimental"], + "operationId": "worktree.remove", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Initialize git repository", - "description": "Create a git repository for the current project and return the refreshed project info.", "responses": { "200": { - "description": "Project information after git initialization", + "description": "Worktree removed", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Project" + "type": "boolean", + "description": "Worktree removed" } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Remove a git worktree and delete its branch.", + "summary": "Remove worktree", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorktreeRemoveInput" + } + } } }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.remove({\n ...\n})" } ] } }, - "/project/{projectID}": { - "patch": { - "operationId": "project.update", + "/experimental/worktree/reset": { + "post": { + "tags": ["experimental"], + "operationId": "worktree.reset", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "in": "path", - "name": "projectID", - "schema": { - "type": "string" - }, - "required": true } ], - "summary": "Update project", - "description": "Update project properties such as name, icon, and commands.", "responses": { "200": { - "description": "Updated project information", + "description": "Worktree reset", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Project" + "type": "boolean", + "description": "Worktree reset" } } } @@ -975,51 +1171,15 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } } }, + "description": "Reset a worktree branch to the primary default branch.", + "summary": "Reset worktree", "requestBody": { "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "icon": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "override": { - "type": "string" - }, - "color": { - "type": "string" - } - } - }, - "commands": { - "type": "object", - "properties": { - "start": { - "description": "Startup script to run when creating a new workspace (worktree)", - "type": "string" - } - } - } - } + "$ref": "#/components/schemas/WorktreeResetInput" } } } @@ -1027,88 +1187,97 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.reset({\n ...\n})" } ] } }, - "/pty/shells": { + "/experimental/session": { "get": { - "operationId": "pty.shells", + "tags": ["experimental"], + "operationId": "experimental.session.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - } - ], - "summary": "List available shells", - "description": "Get a list of available shells on the system.", - "responses": { - "200": { - "description": "List of shells", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "path": { - "type": "string" - }, - "name": { - "type": "string" - }, - "acceptable": { - "type": "boolean" - } - }, - "required": ["path", "name", "acceptable"] - } + }, + { + "name": "roots", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] } - } - } - } - }, - "x-codeSamples": [ + ] + }, + "required": false + }, { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.shells({\n ...\n})" - } - ] - } - }, - "/pty": { - "get": { - "operationId": "pty.list", - "parameters": [ + "name": "start", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, { + "name": "cursor", "in": "query", - "name": "directory", "schema": { - "type": "string" - } + "type": "number" + }, + "required": false }, { + "name": "search", "in": "query", - "name": "workspace", "schema": { "type": "string" - } + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "archived", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] + }, + "required": false } ], - "summary": "List PTY sessions", - "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", "responses": { "200": { "description": "List of sessions", @@ -1117,946 +1286,1009 @@ "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Pty" - } + "$ref": "#/components/schemas/GlobalSession" + }, + "description": "List of sessions" } } } } }, + "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", + "summary": "List sessions", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.session.list({\n ...\n})" } ] - }, - "post": { - "operationId": "pty.create", + } + }, + "/experimental/resource": { + "get": { + "tags": ["experimental"], + "operationId": "experimental.resource.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Create PTY session", - "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", "responses": { "200": { - "description": "Created session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "400": { - "description": "Bad request", + "description": "MCP resources", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "cwd": { - "type": "string" - }, - "title": { - "type": "string" + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/McpResource" }, - "env": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } + "description": "MCP resources" } } } } }, + "description": "Get all available MCP resources from connected servers. Optionally filter by name.", + "summary": "Get MCP resources", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.resource.list({\n ...\n})" } ] } }, - "/pty/{ptyID}": { + "/find": { "get": { - "operationId": "pty.get", + "tags": ["file"], + "operationId": "find.text", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", - "name": "ptyID", + "name": "pattern", + "in": "query", "schema": { - "type": "string", - "pattern": "^pty.*" + "type": "string" }, "required": true } ], - "summary": "Get PTY session", - "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", "responses": { "200": { - "description": "Session info", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "404": { - "description": "Not found", + "description": "Matches", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "x-codeSamples": [ - { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "lines": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "line_number": { + "type": "integer", + "minimum": 0 + }, + "absolute_offset": { + "type": "integer", + "minimum": 0 + }, + "submatches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "match": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + }, + "start": { + "type": "integer", + "minimum": 0 + }, + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["match", "start", "end"], + "additionalProperties": false + } + } + }, + "required": ["path", "lines", "line_number", "absolute_offset", "submatches"], + "additionalProperties": false + }, + "description": "Matches" + } + } + } + } + }, + "description": "Search for text patterns across files in the project using ripgrep.", + "summary": "Find text", + "x-codeSamples": [ + { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" } ] - }, - "put": { - "operationId": "pty.update", + } + }, + "/find/file": { + "get": { + "tags": ["file"], + "operationId": "find.files", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", - "name": "ptyID", + "name": "query", + "in": "query", "schema": { - "type": "string", - "pattern": "^pty.*" + "type": "string" }, "required": true + }, + { + "name": "dirs", + "in": "query", + "schema": { + "type": "string", + "enum": ["true", "false"] + }, + "required": false + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string", + "enum": ["file", "directory"] + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "required": false } ], - "summary": "Update PTY session", - "description": "Update properties of an existing pseudo-terminal (PTY) session.", "responses": { "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pty" - } - } - } - }, - "400": { - "description": "Bad request", + "description": "File paths", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { + "type": "array", + "items": { "type": "string" }, - "size": { - "type": "object", - "properties": { - "rows": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "cols": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["rows", "cols"] - } + "description": "File paths" } } } } }, + "description": "Search for files or directories by name or pattern in the project directory.", + "summary": "Find files", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" } ] - }, - "delete": { - "operationId": "pty.remove", + } + }, + "/find/symbol": { + "get": { + "tags": ["file"], + "operationId": "find.symbols", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", - "name": "ptyID", + "name": "query", + "in": "query", "schema": { - "type": "string", - "pattern": "^pty.*" + "type": "string" }, "required": true } ], - "summary": "Remove PTY session", - "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", "responses": { "200": { - "description": "Session removed", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "404": { - "description": "Not found", + "description": "Symbols", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "type": "array", + "items": { + "$ref": "#/components/schemas/Symbol" + }, + "description": "Symbols" } } } } }, + "description": "Search for workspace symbols like functions, classes, and variables using LSP.", + "summary": "Find symbols", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" } ] } }, - "/pty/{ptyID}/connect": { + "/file": { "get": { - "operationId": "pty.connect", + "tags": ["file"], + "operationId": "file.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", - "name": "ptyID", + "name": "path", + "in": "query", "schema": { - "type": "string", - "pattern": "^pty.*" + "type": "string" }, "required": true } ], - "summary": "Connect to PTY session", - "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", "responses": { "200": { - "description": "Connected session", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "404": { - "description": "Not found", + "description": "Files and directories", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "type": "array", + "items": { + "$ref": "#/components/schemas/FileNode" + }, + "description": "Files and directories" } } } } }, + "description": "List files and directories in a specified path.", + "summary": "List files", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" } ] } }, - "/config": { + "/file/content": { "get": { - "operationId": "config.get", + "tags": ["file"], + "operationId": "file.read", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "path", + "in": "query", + "schema": { + "type": "string" + }, + "required": true } ], - "summary": "Get configuration", - "description": "Retrieve the current OpenCode configuration settings and preferences.", "responses": { "200": { - "description": "Get config info", + "description": "File content", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Config" + "$ref": "#/components/schemas/FileContent" } } } } }, + "description": "Read the content of a specified file.", + "summary": "Read file", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" } ] - }, - "patch": { - "operationId": "config.update", + } + }, + "/file/status": { + "get": { + "tags": ["file"], + "operationId": "file.status", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Update configuration", - "description": "Update OpenCode configuration settings and preferences.", "responses": { "200": { - "description": "Successfully updated config", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Config" - } - } - } - }, - "400": { - "description": "Bad request", + "description": "File status", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/File" + }, + "description": "File status" } } } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Config" - } - } - } - }, + "description": "Get the git status of all files in the project.", + "summary": "Get file status", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" } ] } }, - "/config/providers": { - "get": { - "operationId": "config.providers", + "/instance/dispose": { + "post": { + "tags": ["instance"], + "operationId": "instance.dispose", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List config providers", - "description": "Get a list of all configured AI providers and their default models.", "responses": { "200": { - "description": "List of providers", + "description": "Instance disposed", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "providers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Provider" - } - }, - "default": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["providers", "default"] + "type": "boolean", + "description": "Instance disposed" } } } } }, + "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", + "summary": "Dispose instance", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.providers({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" } ] } }, - "/experimental/console": { + "/path": { "get": { - "operationId": "experimental.console.get", + "tags": ["instance"], + "operationId": "path.get", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get active Console provider metadata", - "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", "responses": { "200": { - "description": "Active Console provider metadata", + "description": "Path", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ConsoleState" + "$ref": "#/components/schemas/Path" } } } } }, + "description": "Retrieve the current working directory and related path information for the OpenCode instance.", + "summary": "Get paths", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" } ] } }, - "/experimental/console/orgs": { + "/vcs": { "get": { - "operationId": "experimental.console.listOrgs", + "tags": ["instance"], + "operationId": "vcs.get", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List switchable Console orgs", - "description": "Get the available Console orgs across logged-in accounts, including the current active org.", "responses": { "200": { - "description": "Switchable Console orgs", + "description": "VCS info", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "orgs": { - "type": "array", - "items": { - "type": "object", - "properties": { - "accountID": { - "type": "string" - }, - "accountEmail": { - "type": "string" - }, - "accountUrl": { - "type": "string" - }, - "orgID": { - "type": "string" - }, - "orgName": { - "type": "string" - }, - "active": { - "type": "boolean" - } - }, - "required": ["accountID", "accountEmail", "accountUrl", "orgID", "orgName", "active"] - } - } - }, - "required": ["orgs"] + "$ref": "#/components/schemas/VcsInfo" } } } } }, + "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", + "summary": "Get VCS info", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.listOrgs({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" } ] } }, - "/experimental/console/switch": { - "post": { - "operationId": "experimental.console.switchOrg", + "/vcs/diff": { + "get": { + "tags": ["instance"], + "operationId": "vcs.diff", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "mode", + "in": "query", + "schema": { + "type": "string", + "enum": ["git", "branch"] + }, + "required": true } ], - "summary": "Switch active Console org", - "description": "Persist a new active Console account/org selection for the current local OpenCode state.", "responses": { "200": { - "description": "Switch success", + "description": "VCS diff", "content": { "application/json": { "schema": { - "type": "boolean" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "accountID": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/components/schemas/VcsFileDiff" }, - "orgID": { - "type": "string" - } - }, - "required": ["accountID", "orgID"] + "description": "VCS diff" + } } } } }, + "description": "Retrieve the current git diff for the working tree or against the default branch.", + "summary": "Get VCS diff", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.console.switchOrg({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})" } ] } }, - "/experimental/tool/ids": { + "/command": { "get": { - "operationId": "tool.ids", + "tags": ["instance"], + "operationId": "command.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List tool IDs", - "description": "Get a list of all available tool IDs, including both built-in tools and dynamically registered tools.", "responses": { "200": { - "description": "Tool IDs", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ToolIDs" - } - } - } - }, - "400": { - "description": "Bad request", + "description": "List of commands", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/Command" + }, + "description": "List of commands" } } } } }, + "description": "Get a list of all available commands in the OpenCode system.", + "summary": "List commands", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.ids({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" } ] } }, - "/experimental/tool": { + "/agent": { "get": { - "operationId": "tool.list", + "tags": ["instance"], + "operationId": "app.agents", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", "in": "query", - "name": "provider", - "schema": { - "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "model", + "required": false, "schema": { "type": "string" - }, - "required": true + } } ], - "summary": "List tools", - "description": "Get a list of available tools with their JSON schema parameters for a specific provider and model combination.", "responses": { "200": { - "description": "Tools", + "description": "List of agents", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ToolList" + "type": "array", + "items": { + "$ref": "#/components/schemas/Agent" + }, + "description": "List of agents" } } } - }, - "400": { - "description": "Bad request", + } + }, + "description": "Get a list of all available AI agents in the OpenCode system.", + "summary": "List agents", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" + } + ] + } + }, + "/skill": { + "get": { + "tags": ["instance"], + "operationId": "app.skills", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of skills", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "location": { + "type": "string" + }, + "content": { + "type": "string" + } + }, + "required": ["name", "description", "location", "content"], + "additionalProperties": false + }, + "description": "List of skills" } } } } }, + "description": "Get a list of all available skills in the OpenCode system.", + "summary": "List skills", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.tool.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.skills({\n ...\n})" } ] } }, - "/experimental/worktree": { - "post": { - "operationId": "worktree.create", + "/lsp": { + "get": { + "tags": ["instance"], + "operationId": "lsp.status", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Create worktree", - "description": "Create a new git worktree for the current project and run any configured startup scripts.", "responses": { "200": { - "description": "Worktree created", + "description": "LSP server status", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Worktree" + "type": "array", + "items": { + "$ref": "#/components/schemas/LSPStatus" + }, + "description": "LSP server status" } } } + } + }, + "description": "Get LSP server status", + "summary": "Get LSP status", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" + } + ] + } + }, + "/formatter": { + "get": { + "tags": ["instance"], + "operationId": "formatter.status", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } }, - "400": { - "description": "Bad request", + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Formatter status", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "type": "array", + "items": { + "$ref": "#/components/schemas/FormatterStatus" + }, + "description": "Formatter status" } } } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorktreeCreateInput" - } - } - } - }, + "description": "Get formatter status", + "summary": "Get formatter status", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.create({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" } ] - }, + } + }, + "/mcp": { "get": { - "operationId": "worktree.list", + "tags": ["mcp"], + "operationId": "mcp.status", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List worktrees", - "description": "List all sandbox worktrees for the current project.", "responses": { "200": { - "description": "List of worktree directories", + "description": "MCP server status", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "string" - } + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + }, + "description": "MCP server status" } } } } }, + "description": "Get the status of all Model Context Protocol (MCP) servers.", + "summary": "Get MCP status", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" } ] }, - "delete": { - "operationId": "worktree.remove", + "post": { + "tags": ["mcp"], + "operationId": "mcp.add", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Remove worktree", - "description": "Remove a git worktree and delete its branch.", "responses": { "200": { - "description": "Worktree removed", + "description": "MCP server added successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/MCPStatus" + }, + "description": "MCP server added successfully" } } } @@ -2072,11 +2304,30 @@ } } }, + "description": "Dynamically add a new Model Context Protocol (MCP) server to the system.", + "summary": "Add MCP server", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/WorktreeRemoveInput" + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "config": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" + } + ] + } + }, + "required": ["name", "config"], + "additionalProperties": false } } } @@ -2084,215 +2335,239 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" } ] } }, - "/experimental/worktree/reset": { + "/mcp/{name}/auth": { "post": { - "operationId": "worktree.reset", + "tags": ["mcp"], + "operationId": "mcp.auth.start", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true } ], - "summary": "Reset worktree", - "description": "Reset a worktree branch to the primary default branch.", "responses": { "200": { - "description": "Worktree reset", + "description": "OAuth flow started", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string" + }, + "oauthState": { + "type": "string" + } + }, + "required": ["authorizationUrl", "oauthState"], + "additionalProperties": false, + "description": "OAuth flow started" } } } }, "400": { - "description": "Bad request", + "description": "McpUnsupportedOAuthError", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/McpUnsupportedOAuthError" } } } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WorktreeResetInput" + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } } } } }, + "description": "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", + "summary": "Start MCP OAuth", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.worktree.reset({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" } ] - } - }, - "/experimental/session": { - "get": { - "operationId": "experimental.session.list", + }, + "delete": { + "tags": ["mcp"], + "operationId": "mcp.auth.remove", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" - }, - "description": "Filter sessions by project directory" + } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", - "name": "roots", - "schema": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": ["true", "false"] - } - ] - }, - "description": "Only return root sessions (no parentID)" - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "number" - }, - "description": "Filter sessions updated on or after this timestamp (milliseconds since epoch)" - }, - { - "in": "query", - "name": "cursor", - "schema": { - "type": "number" - }, - "description": "Return sessions updated before this timestamp (milliseconds since epoch)" - }, - { - "in": "query", - "name": "search", + "name": "name", + "in": "path", "schema": { "type": "string" }, - "description": "Filter sessions by title (case-insensitive)" - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "number" - }, - "description": "Maximum number of sessions to return" - }, - { - "in": "query", - "name": "archived", - "schema": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": ["true", "false"] - } - ] - }, - "description": "Include archived sessions (default false)" + "required": true } ], - "summary": "List sessions", - "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", "responses": { "200": { - "description": "List of sessions", + "description": "OAuth credentials removed", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/GlobalSession" - } + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + } + }, + "required": ["success"], + "additionalProperties": false, + "description": "OAuth credentials removed" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } } }, + "description": "Remove OAuth credentials for an MCP server.", + "summary": "Remove MCP OAuth", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.session.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" } ] } }, - "/experimental/resource": { - "get": { - "operationId": "experimental.resource.list", + "/mcp/{name}/auth/callback": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.auth.callback", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true } ], - "summary": "Get MCP resources", - "description": "Get all available MCP resources from connected servers. Optionally filter by name.", "responses": { "200": { - "description": "MCP resources", + "description": "OAuth authentication completed", "content": { "application/json": { "schema": { - "type": "object", - "propertyNames": { + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", + "summary": "Complete MCP OAuth", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/McpResource" } - } + }, + "required": ["code"], + "additionalProperties": false } } } @@ -2300,436 +2575,360 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.resource.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" } ] } }, - "/session": { - "get": { - "operationId": "session.list", + "/mcp/{name}/auth/authenticate": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.auth.authenticate", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - }, - "description": "Filter sessions by directory" - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", "in": "query", - "name": "scope", - "schema": { - "type": "string", - "enum": ["project"] - }, - "description": "List all sessions for the current project" - }, - { - "in": "query", - "name": "path", + "required": false, "schema": { "type": "string" - }, - "description": "Filter sessions by project-relative path" - }, - { - "in": "query", - "name": "roots", - "schema": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": ["true", "false"] - } - ] - }, - "description": "Only return root sessions (no parentID)" - }, - { - "in": "query", - "name": "start", - "schema": { - "type": "number" - }, - "description": "Filter sessions updated on or after this timestamp (milliseconds since epoch)" + } }, { - "in": "query", - "name": "search", + "name": "name", + "in": "path", "schema": { "type": "string" }, - "description": "Filter sessions by title (case-insensitive)" - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "number" - }, - "description": "Maximum number of sessions to return" + "required": true } ], - "summary": "List sessions", - "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", "responses": { "200": { - "description": "List of sessions", + "description": "OAuth authentication completed", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Session" - } + "$ref": "#/components/schemas/MCPStatus" + } + } + } + }, + "400": { + "description": "McpUnsupportedOAuthError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpUnsupportedOAuthError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } } }, + "description": "Start OAuth flow and wait for callback (opens browser).", + "summary": "Authenticate MCP OAuth", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" } ] - }, + } + }, + "/mcp/{name}/connect": { "post": { - "operationId": "session.create", + "tags": ["mcp"], + "operationId": "mcp.connect", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - } - ], - "summary": "Create session", - "description": "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], "responses": { "200": { - "description": "Successfully created session", + "description": "MCP server connected successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "type": "boolean", + "description": "MCP server connected successfully" } } } + } + }, + "description": "Connect an MCP server.", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" + } + ] + } + }, + "/mcp/{name}/disconnect": { + "post": { + "tags": ["mcp"], + "operationId": "mcp.disconnect", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } }, - "400": { - "description": "Bad request", + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "name", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "MCP server disconnected successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "parentID": { - "type": "string", - "pattern": "^ses.*" - }, - "title": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"] - }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - } + "type": "boolean", + "description": "MCP server disconnected successfully" } } } } }, + "description": "Disconnect an MCP server.", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.create({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" } ] } }, - "/session/status": { + "/project": { "get": { - "operationId": "session.status", + "tags": ["project"], + "operationId": "project.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get session status", - "description": "Retrieve the current status of all sessions, including active, idle, and completed states.", "responses": { "200": { - "description": "Get session status", + "description": "List of projects", "content": { "application/json": { "schema": { - "type": "object", - "propertyNames": { - "type": "string" + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" }, - "additionalProperties": { - "$ref": "#/components/schemas/SessionStatus" - } - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" + "description": "List of projects" } } } } }, + "description": "Get a list of projects that have been opened with OpenCode.", + "summary": "List all projects", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.list({\n ...\n})" } ] } }, - "/session/{sessionID}": { + "/project/current": { "get": { - "operationId": "session.get", + "tags": ["project"], + "operationId": "project.current", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true } ], - "summary": "Get session", - "description": "Retrieve detailed information about a specific OpenCode session.", - "tags": ["Session"], "responses": { "200": { - "description": "Get session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", + "description": "Current project information", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "$ref": "#/components/schemas/Project" } } } } }, + "description": "Retrieve the currently active project that OpenCode is working with.", + "summary": "Get current project", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.current({\n ...\n})" } ] - }, - "delete": { - "operationId": "session.delete", + } + }, + "/project/git/init": { + "post": { + "tags": ["project"], + "operationId": "project.initGit", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true } ], - "summary": "Delete session", - "description": "Delete a session and permanently remove all associated data, including messages and history.", "responses": { "200": { - "description": "Successfully deleted session", - "content": { - "application/json": { - "schema": { - "type": "boolean" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", + "description": "Project information after git initialization", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "$ref": "#/components/schemas/Project" } } } } }, + "description": "Create a git repository for the current project and return the refreshed project info.", + "summary": "Initialize git repository", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.delete({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.initGit({\n ...\n})" } ] - }, + } + }, + "/project/{projectID}": { "patch": { - "operationId": "session.update", + "tags": ["project"], + "operationId": "project.update", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "projectID", "in": "path", - "name": "sessionID", "schema": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "required": true } ], - "summary": "Update session", - "description": "Update properties of an existing session, such as title or other metadata.", "responses": { "200": { - "description": "Successfully updated session", + "description": "Updated project information", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Project" } } } @@ -2755,27 +2954,44 @@ } } }, + "description": "Update project properties such as name, icon, and commands.", + "summary": "Update project", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "title": { + "name": { "type": "string" }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" + "icon": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "override": { + "type": "string" + }, + "color": { + "type": "string" + } + }, + "additionalProperties": false }, - "time": { + "commands": { "type": "object", "properties": { - "archived": { - "type": "number" + "start": { + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" } - } + }, + "additionalProperties": false } - } + }, + "additionalProperties": false } } } @@ -2783,195 +2999,147 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.project.update({\n ...\n})" } ] } }, - "/session/{sessionID}/children": { + "/pty/shells": { "get": { - "operationId": "session.children", + "tags": ["pty"], + "operationId": "pty.shells", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true } ], - "summary": "Get session children", - "tags": ["Session"], - "description": "Retrieve all child sessions that were forked from the specified parent session.", "responses": { "200": { - "description": "List of children", + "description": "List of shells", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Session" - } - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "acceptable": { + "type": "boolean" + } + }, + "required": ["path", "name", "acceptable"], + "additionalProperties": false + }, + "description": "List of shells" } } } } }, + "description": "Get a list of available shells on the system.", + "summary": "List available shells", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.children({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.shells({\n ...\n})" } ] } }, - "/session/{sessionID}/todo": { + "/pty": { "get": { - "operationId": "session.todo", + "tags": ["pty"], + "operationId": "pty.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true } ], - "summary": "Get session todos", - "description": "Retrieve the todo list associated with a specific session, showing tasks and action items.", "responses": { "200": { - "description": "Todo list", + "description": "List of sessions", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Todo" - } - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" + "$ref": "#/components/schemas/Pty" + }, + "description": "List of sessions" } } } } }, + "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", + "summary": "List PTY sessions", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.todo({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.list({\n ...\n})" } ] - } - }, - "/session/{sessionID}/init": { + }, "post": { - "operationId": "session.init", + "tags": ["pty"], + "operationId": "pty.create", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true } ], - "summary": "Initialize session", - "description": "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", "responses": { "200": { - "description": "200", + "description": "Created session", "content": { "application/json": { "schema": { - "type": "boolean" + "$ref": "#/components/schemas/Pty" } } } @@ -2985,36 +3153,39 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } } }, + "description": "Create a new pseudo-terminal (PTY) session for running shell commands and processes.", + "summary": "Create PTY session", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "modelID": { + "command": { "type": "string" }, - "providerID": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { "type": "string" }, - "messageID": { - "type": "string", - "pattern": "^msg.*" + "title": { + "type": "string" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } } }, - "required": ["modelID", "providerID", "messageID"] + "additionalProperties": false } } } @@ -3022,113 +3193,110 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.create({\n ...\n})" } ] } }, - "/session/{sessionID}/fork": { - "post": { - "operationId": "session.fork", + "/pty/{ptyID}": { + "get": { + "tags": ["pty"], + "operationId": "pty.get", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "ptyID", "in": "path", - "name": "sessionID", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^pty.*" }, "required": true } ], - "summary": "Fork session", - "description": "Create a new session by forking an existing session at a specific message point.", "responses": { "200": { - "description": "200", + "description": "Session info", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "$ref": "#/components/schemas/Pty" } } } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } } }, + "description": "Retrieve detailed information about a specific pseudo-terminal (PTY) session.", + "summary": "Get PTY session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.fork({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.get({\n ...\n})" } ] - } - }, - "/session/{sessionID}/abort": { - "post": { - "operationId": "session.abort", + }, + "put": { + "tags": ["pty"], + "operationId": "pty.update", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "ptyID", "in": "path", - "name": "sessionID", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^pty.*" }, "required": true } ], - "summary": "Abort session", - "description": "Abort an active session and stop any ongoing AI processing or command execution.", "responses": { "200": { - "description": "Aborted session", + "description": "Updated session", "content": { "application/json": { "schema": { - "type": "boolean" + "$ref": "#/components/schemas/Pty" } } } @@ -3142,6 +3310,88 @@ } } } + } + }, + "description": "Update properties of an existing pseudo-terminal (PTY) session.", + "summary": "Update PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "size": { + "type": "object", + "properties": { + "rows": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "cols": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["rows", "cols"], + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.update({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["pty"], + "operationId": "pty.remove", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Session removed", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Session removed" + } + } + } }, "404": { "description": "Not found", @@ -3154,51 +3404,103 @@ } } }, + "description": "Remove and terminate a specific pseudo-terminal (PTY) session.", + "summary": "Remove PTY session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.abort({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.remove({\n ...\n})" } ] } }, - "/session/{sessionID}/share": { - "post": { - "operationId": "session.share", + "/question": { + "get": { + "tags": ["question"], + "operationId": "question.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of pending questions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionRequest" + }, + "description": "List of pending questions" + } + } + } + } + }, + "description": "Get all pending question requests across all sessions.", + "summary": "List pending questions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})" + } + ] + } + }, + "/question/{requestID}/reply": { + "post": { + "tags": ["question"], + "operationId": "question.reply", + "parameters": [ + { + "name": "directory", "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "requestID", "in": "path", - "name": "sessionID", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^que.*" }, "required": true } ], - "summary": "Share session", - "description": "Create a shareable link for a session, allowing others to view the conversation.", "responses": { "200": { - "description": "Successfully shared session", + "description": "Question answered successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "type": "boolean", + "description": "Question answered successfully" } } } @@ -3224,49 +3526,75 @@ } } }, + "description": "Provide answers to a question request from the AI assistant.", + "summary": "Reply to question request", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + }, + "description": "User answers in order of questions (each answer is an array of selected labels)" + } + }, + "required": ["answers"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.share({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})" } ] - }, - "delete": { - "operationId": "session.unshare", + } + }, + "/question/{requestID}/reject": { + "post": { + "tags": ["question"], + "operationId": "question.reject", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "requestID", "in": "path", - "name": "sessionID", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^que.*" }, "required": true } ], - "summary": "Unshare session", - "description": "Remove the shareable link for a session, making it private again.", "responses": { "200": { - "description": "Successfully unshared session", + "description": "Question rejected successfully", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "type": "boolean", + "description": "Question rejected successfully" } } } @@ -3292,112 +3620,103 @@ } } }, + "description": "Reject a question request from the AI assistant.", + "summary": "Reject question request", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unshare({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" } ] } }, - "/session/{sessionID}/diff": { + "/permission": { "get": { - "operationId": "session.diff", + "tags": ["permission"], + "operationId": "permission.list", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { + "name": "workspace", "in": "query", - "name": "messageID", + "required": false, "schema": { - "type": "string", - "pattern": "^msg.*" + "type": "string" } } ], - "summary": "Get message diff", - "description": "Get the file changes (diff) that resulted from a specific user message in the session.", "responses": { "200": { - "description": "Successfully retrieved diff", + "description": "List of pending permissions", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } + "$ref": "#/components/schemas/PermissionRequest" + }, + "description": "List of pending permissions" } } } } }, + "description": "Get all pending permission requests across all sessions.", + "summary": "List pending permissions", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" } ] } }, - "/session/{sessionID}/summarize": { + "/permission/{requestID}/reply": { "post": { - "operationId": "session.summarize", + "tags": ["permission"], + "operationId": "permission.reply", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "requestID", "in": "path", - "name": "sessionID", "schema": { "type": "string", - "pattern": "^ses.*" + "pattern": "^per.*" }, "required": true } ], - "summary": "Summarize session", - "description": "Generate a concise summary of the session using AI compaction to preserve key information.", "responses": { "200": { - "description": "Summarized session", + "description": "Permission processed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Permission processed successfully" } } } @@ -3423,24 +3742,24 @@ } } }, + "description": "Approve or deny a permission request from the AI assistant.", + "summary": "Respond to permission request", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "providerID": { - "type": "string" + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] }, - "modelID": { + "message": { "type": "string" - }, - "auto": { - "default": false, - "type": "boolean" } }, - "required": ["providerID", "modelID"] + "required": ["reply"], + "additionalProperties": false } } } @@ -3448,160 +3767,166 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.summarize({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" } ] } }, - "/session/{sessionID}/message": { + "/provider": { "get": { - "operationId": "session.messages", + "tags": ["provider"], + "operationId": "provider.list", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "description": "Maximum number of messages to return" - }, - { + "name": "workspace", "in": "query", - "name": "before", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get session messages", - "description": "Retrieve all messages in a session, including user prompts and AI responses.", "responses": { "200": { - "description": "List of messages", + "description": "List of providers", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Message" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } + "type": "object", + "properties": { + "all": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Provider" } }, - "required": ["info", "parts"] - } + "default": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "connected": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["all", "default", "connected"], + "additionalProperties": false, + "description": "List of providers" } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } + } + }, + "description": "Get a list of all available AI providers, including both available and connected ones.", + "summary": "List providers", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" + } + ] + } + }, + "/provider/auth": { + "get": { + "tags": ["provider"], + "operationId": "provider.auth", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" } }, - "404": { - "description": "Not found", + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Provider auth methods", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProviderAuthMethod" + } + }, + "description": "Provider auth methods" } } } } }, + "description": "Retrieve available authentication methods for all AI providers.", + "summary": "Get provider auth methods", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" } ] - }, + } + }, + "/provider/{providerID}/oauth/authorize": { "post": { - "operationId": "session.prompt", + "tags": ["provider"], + "operationId": "provider.oauth.authorize", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "providerID", "in": "path", - "name": "sessionID", "schema": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "required": true } ], - "summary": "Send message", - "description": "Create and send a new message to a session, streaming the AI response.", "responses": { "200": { - "description": "Created message", + "description": "Authorization URL and method", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/AssistantMessage" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] + "$ref": "#/components/schemas/ProviderAuthAuthorization" } } } @@ -3615,86 +3940,29 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } } }, + "description": "Start the OAuth authorization flow for a provider.", + "summary": "Start OAuth authorization", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "agent": { - "type": "string" - }, - "noReply": { - "type": "boolean" + "method": { + "type": "number", + "description": "Auth method index" }, - "tools": { - "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", + "inputs": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { - "type": "boolean" - } - }, - "format": { - "$ref": "#/components/schemas/OutputFormat" - }, - "system": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/TextPartInput" - }, - { - "$ref": "#/components/schemas/FilePartInput" - }, - { - "$ref": "#/components/schemas/AgentPartInput" - }, - { - "$ref": "#/components/schemas/SubtaskPartInput" - } - ] + "type": "string" } } }, - "required": ["parts"] + "required": ["method"], + "additionalProperties": false } } } @@ -3702,69 +3970,49 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" } ] } }, - "/session/{sessionID}/message/{messageID}": { - "get": { - "operationId": "session.message", + "/provider/{providerID}/oauth/callback": { + "post": { + "tags": ["provider"], + "operationId": "provider.oauth.callback", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "providerID", "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "path", - "name": "messageID", "schema": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "required": true } ], - "summary": "Get message", - "description": "Retrieve a specific message from a session by its message ID.", "responses": { "200": { - "description": "Message", + "description": "OAuth callback processed successfully", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Message" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] + "type": "boolean", + "description": "OAuth callback processed successfully" } } } @@ -3778,14 +4026,26 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } + } + }, + "description": "Handle the OAuth callback from a provider after user authorization.", + "summary": "Handle OAuth callback", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "method": { + "type": "number", + "description": "Auth method index" + }, + "code": { + "type": "string" + } + }, + "required": ["method"], + "additionalProperties": false } } } @@ -3793,142 +4053,244 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" } ] - }, - "delete": { - "operationId": "session.deleteMessage", + } + }, + "/session": { + "get": { + "tags": ["session"], + "operationId": "session.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", - "name": "sessionID", + "name": "scope", + "in": "query", "schema": { "type": "string", - "pattern": "^ses.*" + "enum": ["project"] }, - "required": true + "required": false }, { - "in": "path", - "name": "messageID", + "name": "path", + "in": "query", "schema": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, - "required": true + "required": false + }, + { + "name": "roots", + "in": "query", + "schema": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": ["true", "false"] + } + ] + }, + "required": false + }, + { + "name": "start", + "in": "query", + "schema": { + "type": "number" + }, + "required": false + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "number" + }, + "required": false } ], - "summary": "Delete message", - "description": "Permanently delete a specific message (and all of its parts) from a session. This does not revert any file changes that may have been made while processing the message.", "responses": { "200": { - "description": "Successfully deleted message", + "description": "List of sessions", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "array", + "items": { + "$ref": "#/components/schemas/Session" + }, + "description": "List of sessions" } } } + } + }, + "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", + "summary": "List sessions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.list({\n ...\n})" + } + ] + }, + "post": { + "tags": ["session"], + "operationId": "session.create", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } }, - "400": { - "description": "Bad request", + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully created session", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BadRequestError" + "$ref": "#/components/schemas/Session" } } } }, - "404": { - "description": "Not found", + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "$ref": "#/components/schemas/BadRequestError" } } } } }, + "description": "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + "summary": "Create session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "parentID": { + "type": "string" + }, + "title": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "workspaceID": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.deleteMessage({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.create({\n ...\n})" } ] } }, - "/session/{sessionID}/message/{messageID}/part/{partID}": { - "delete": { - "operationId": "part.delete", + "/session/status": { + "get": { + "tags": ["session"], + "operationId": "session.status", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "in": "path", - "name": "sessionID", - "schema": { - "type": "string", - "pattern": "^ses.*" - }, - "required": true - }, - { - "in": "path", - "name": "messageID", - "schema": { - "type": "string", - "pattern": "^msg.*" - }, - "required": true - }, - { - "in": "path", - "name": "partID", - "schema": { - "type": "string", - "pattern": "^prt.*" - }, - "required": true } ], - "description": "Delete a part from a message", "responses": { "200": { - "description": "Successfully deleted part", + "description": "Get session status", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/SessionStatus" + }, + "description": "Get session status" } } } @@ -3942,78 +4304,56 @@ } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } } }, + "description": "Retrieve the current status of all sessions, including active, idle, and completed states.", + "summary": "Get session status", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.delete({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.status({\n ...\n})" } ] - }, - "patch": { - "operationId": "part.update", + } + }, + "/session/{sessionID}": { + "get": { + "tags": ["session"], + "operationId": "session.get", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" }, "required": true - }, - { - "in": "path", - "name": "messageID", - "schema": { - "type": "string", - "pattern": "^msg.*" - }, - "required": true - }, - { - "in": "path", - "name": "partID", - "schema": { - "type": "string", - "pattern": "^prt.*" - }, - "required": true } ], - "description": "Update a part in a message", "responses": { "200": { - "description": "Successfully updated part", + "description": "Get session", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Part" + "$ref": "#/components/schemas/Session" } } } @@ -4039,44 +4379,38 @@ } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Part" - } - } - } - }, + "description": "Retrieve detailed information about a specific OpenCode session.", + "summary": "Get session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.update({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.get({\n ...\n})" } ] - } - }, - "/session/{sessionID}/prompt_async": { - "post": { - "operationId": "session.prompt_async", + }, + "delete": { + "tags": ["session"], + "operationId": "session.delete", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -4084,11 +4418,17 @@ "required": true } ], - "summary": "Send async message", - "description": "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", "responses": { - "204": { - "description": "Prompt accepted" + "200": { + "description": "Successfully deleted session", + "content": { + "application/json": { + "schema": { + "type": "boolean", + "description": "Successfully deleted session" + } + } + } }, "400": { "description": "Bad request", @@ -4111,107 +4451,38 @@ } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "agent": { - "type": "string" - }, - "noReply": { - "type": "boolean" - }, - "tools": { - "description": "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, - "format": { - "$ref": "#/components/schemas/OutputFormat" - }, - "system": { - "type": "string" - }, - "variant": { - "type": "string" - }, - "parts": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/TextPartInput" - }, - { - "$ref": "#/components/schemas/FilePartInput" - }, - { - "$ref": "#/components/schemas/AgentPartInput" - }, - { - "$ref": "#/components/schemas/SubtaskPartInput" - } - ] - } - } - }, - "required": ["parts"] - } - } - } - }, + "description": "Delete a session and permanently remove all associated data, including messages and history.", + "summary": "Delete session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.delete({\n ...\n})" } ] - } - }, - "/session/{sessionID}/command": { - "post": { - "operationId": "session.command", + }, + "patch": { + "tags": ["session"], + "operationId": "session.update", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -4219,27 +4490,13 @@ "required": true } ], - "summary": "Send command", - "description": "Send a new command to a session for execution by the AI assistant.", "responses": { "200": { - "description": "Created message", + "description": "Successfully updated session", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/AssistantMessage" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } - }, - "required": ["info", "parts"] + "$ref": "#/components/schemas/Session" } } } @@ -4265,62 +4522,31 @@ } } }, + "description": "Update properties of an existing session, such as title or other metadata.", + "summary": "Update session", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "string" - }, - "arguments": { - "type": "string" - }, - "command": { + "title": { "type": "string" }, - "variant": { - "type": "string" + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" }, - "parts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "file" - }, - "mime": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/FilePartSource" - } - }, - "required": ["type", "mime", "url"] - } + "time": { + "type": "object", + "properties": { + "archived": { + "type": "number" + } + }, + "additionalProperties": false } }, - "required": ["arguments", "command"] + "additionalProperties": false } } } @@ -4328,32 +4554,35 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.update({\n ...\n})" } ] } }, - "/session/{sessionID}/shell": { - "post": { - "operationId": "session.shell", + "/session/{sessionID}/children": { + "get": { + "tags": ["session"], + "operationId": "session.children", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -4361,27 +4590,17 @@ "required": true } ], - "summary": "Run shell command", - "description": "Execute a shell command within the session context and return the AI's response.", "responses": { "200": { - "description": "Created message", + "description": "List of children", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Message" - }, - "parts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Part" - } - } + "type": "array", + "items": { + "$ref": "#/components/schemas/Session" }, - "required": ["info", "parts"] + "description": "List of children" } } } @@ -4407,69 +4626,40 @@ } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "providerID": { - "type": "string" - }, - "modelID": { - "type": "string" - } - }, - "required": ["providerID", "modelID"] - }, - "command": { - "type": "string" - } - }, - "required": ["agent", "command"] - } - } - } - }, + "description": "Retrieve all child sessions that were forked from the specified parent session.", + "summary": "Get session children", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.children({\n ...\n})" } ] } }, - "/session/{sessionID}/revert": { - "post": { - "operationId": "session.revert", + "/session/{sessionID}/todo": { + "get": { + "tags": ["session"], + "operationId": "session.todo", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -4477,15 +4667,17 @@ "required": true } ], - "summary": "Revert message", - "description": "Revert a specific message in a session, undoing its effects and restoring the previous state.", "responses": { "200": { - "description": "Updated session", + "description": "Todo list", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Session" + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + }, + "description": "Todo list" } } } @@ -4511,125 +4703,106 @@ } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - } - }, - "required": ["messageID"] - } - } - } - }, + "description": "Retrieve the todo list associated with a specific session, showing tasks and action items.", + "summary": "Get session todos", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.todo({\n ...\n})" } ] } }, - "/session/{sessionID}/unrevert": { - "post": { - "operationId": "session.unrevert", + "/session/{sessionID}/diff": { + "get": { + "tags": ["session"], + "operationId": "session.diff", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" }, "required": true + }, + { + "name": "messageID", + "in": "query", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": false } ], - "summary": "Restore reverted messages", - "description": "Restore all previously reverted messages in a session.", "responses": { "200": { - "description": "Updated session", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Session" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", + "description": "Successfully retrieved diff", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + }, + "description": "Successfully retrieved diff" } } } } }, + "description": "Get the file changes (diff) that resulted from a specific user message in the session.", + "summary": "Get message diff", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.diff({\n ...\n})" } ] } }, - "/session/{sessionID}/permissions/{permissionID}": { - "post": { - "operationId": "permission.respond", + "/session/{sessionID}/message": { + "get": { + "tags": ["session"], + "operationId": "session.messages", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "path", "name": "sessionID", + "in": "path", "schema": { "type": "string", "pattern": "^ses.*" @@ -4637,25 +4810,48 @@ "required": true }, { - "in": "path", - "name": "permissionID", + "name": "limit", + "in": "query", "schema": { - "type": "string", - "pattern": "^per.*" + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 }, - "required": true + "required": false + }, + { + "name": "before", + "in": "query", + "schema": { + "type": "string" + }, + "required": false } ], - "summary": "Respond to permission", - "deprecated": true, - "description": "Approve or deny a permission request from the AI assistant.", "responses": { "200": { - "description": "Permission processed successfully", + "description": "List of messages", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "array", + "items": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"], + "additionalProperties": false + }, + "description": "List of messages" } } } @@ -4681,67 +4877,64 @@ } } }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "response": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["response"] - } - } - } - }, + "description": "Retrieve all messages in a session, including user prompts and AI responses.", + "summary": "Get session messages", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.messages({\n ...\n})" } ] - } - }, - "/permission/{requestID}/reply": { + }, "post": { - "operationId": "permission.reply", + "tags": ["session"], + "operationId": "session.prompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "sessionID", "in": "path", - "name": "requestID", "schema": { "type": "string", - "pattern": "^per.*" + "pattern": "^ses.*" }, "required": true } ], - "summary": "Respond to permission request", - "description": "Approve or deny a permission request from the AI assistant.", "responses": { "200": { - "description": "Permission processed successfully", + "description": "Created message", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "object", + "required": ["info", "parts"], + "properties": { + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + } } } } @@ -4767,65 +4960,73 @@ } } }, + "description": "Create and send a new message to a session, streaming the AI response.", + "summary": "Send message", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] + "messageID": { + "type": "string" }, - "message": { + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "agent": { "type": "string" + }, + "noReply": { + "type": "boolean" + }, + "tools": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "format": { + "$ref": "#/components/schemas/OutputFormat" + }, + "system": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPartInput" + }, + { + "$ref": "#/components/schemas/FilePartInput" + }, + { + "$ref": "#/components/schemas/AgentPartInput" + }, + { + "$ref": "#/components/schemas/SubtaskPartInput" + } + ] + } } - }, - "required": ["reply"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.reply({\n ...\n})" - } - ] - } - }, - "/permission": { - "get": { - "operationId": "permission.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List pending permissions", - "description": "Get all pending permission requests across all sessions.", - "responses": { - "200": { - "description": "List of pending permissions", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PermissionRequest" - } - } + }, + "required": ["parts"], + "additionalProperties": false } } } @@ -4833,92 +5034,153 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt({\n ...\n})" } ] } }, - "/question": { + "/session/{sessionID}/message/{messageID}": { "get": { - "operationId": "question.list", + "tags": ["session"], + "operationId": "session.message", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": true } ], - "summary": "List pending questions", - "description": "Get all pending question requests across all sessions.", "responses": { "200": { - "description": "List of pending questions", + "description": "Message", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionRequest" - } + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"], + "additionalProperties": false, + "description": "Message" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } } }, + "description": "Retrieve a specific message from a session by its message ID.", + "summary": "Get message", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.message({\n ...\n})" } ] - } - }, - "/question/{requestID}/reply": { - "post": { - "operationId": "question.reply", + }, + "delete": { + "tags": ["session"], + "operationId": "session.deleteMessage", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "sessionID", "in": "path", - "name": "requestID", "schema": { "type": "string", - "pattern": "^que.*" + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg.*" }, "required": true } ], - "summary": "Reply to question request", - "description": "Provide answers to a question request from the AI assistant.", "responses": { "200": { - "description": "Question answered successfully", + "description": "Successfully deleted message", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Successfully deleted message" } } } @@ -4944,21 +5206,72 @@ } } }, + "description": "Permanently delete a specific message and all of its parts from a session without reverting file changes.", + "summary": "Delete message", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.deleteMessage({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/fork": { + "post": { + "tags": ["session"], + "operationId": "session.fork", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "200", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + } + }, + "description": "Create a new session by forking an existing session at a specific message point.", + "summary": "Fork session", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "answers": { - "description": "User answers in order of questions (each answer is an array of selected labels)", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } + "messageID": { + "type": "string" } }, - "required": ["answers"] + "additionalProperties": false } } } @@ -4966,48 +5279,50 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reply({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.fork({\n ...\n})" } ] } }, - "/question/{requestID}/reject": { + "/session/{sessionID}/abort": { "post": { - "operationId": "question.reject", + "tags": ["session"], + "operationId": "session.abort", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "sessionID", "in": "path", - "name": "requestID", "schema": { "type": "string", - "pattern": "^que.*" + "pattern": "^ses.*" }, "required": true } ], - "summary": "Reject question request", - "description": "Reject a question request from the AI assistant.", "responses": { "200": { - "description": "Question rejected successfully", + "description": "Aborted session", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Aborted session" } } } @@ -5033,166 +5348,221 @@ } } }, + "description": "Abort an active session and stop any ongoing AI processing or command execution.", + "summary": "Abort session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.abort({\n ...\n})" } ] } }, - "/provider": { - "get": { - "operationId": "provider.list", + "/session/{sessionID}/init": { + "post": { + "tags": ["session"], + "operationId": "session.init", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true } ], - "summary": "List providers", - "description": "Get a list of all available AI providers, including both available and connected ones.", "responses": { "200": { - "description": "List of providers", + "description": "200", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "all": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Provider" - } - }, - "default": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "connected": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["all", "default", "connected"] + "type": "boolean", + "description": "200" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" } } } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + "summary": "Initialize session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "modelID": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["modelID", "providerID", "messageID"], + "additionalProperties": false + } + } } }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.init({\n ...\n})" } ] } }, - "/provider/auth": { - "get": { - "operationId": "provider.auth", + "/session/{sessionID}/share": { + "post": { + "tags": ["session"], + "operationId": "session.share", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true } ], - "summary": "Get provider auth methods", - "description": "Retrieve available authentication methods for all AI providers.", "responses": { "200": { - "description": "Provider auth methods", + "description": "Successfully shared session", "content": { "application/json": { "schema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProviderAuthMethod" - } - } + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } } }, + "description": "Create a shareable link for a session, allowing others to view the conversation.", + "summary": "Share session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.auth({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.share({\n ...\n})" } ] - } - }, - "/provider/{providerID}/oauth/authorize": { - "post": { - "operationId": "provider.oauth.authorize", + }, + "delete": { + "tags": ["session"], + "operationId": "session.unshare", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "sessionID", "in": "path", - "name": "providerID", "schema": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, - "required": true, - "description": "Provider ID" + "required": true } ], - "summary": "OAuth authorize", - "description": "Initiate OAuth authorization for a specific AI provider to get an authorization URL.", "responses": { "200": { - "description": "Authorization URL and method", + "description": "Successfully unshared session", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ProviderAuthAuthorization" + "$ref": "#/components/schemas/Session" } } } @@ -5206,79 +5576,67 @@ } } } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "method": { - "description": "Auth method index", - "type": "number" - }, - "inputs": { - "description": "Prompt inputs", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - } - }, - "required": ["method"] + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } } } } }, + "description": "Remove the shareable link for a session, making it private again.", + "summary": "Unshare session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.authorize({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unshare({\n ...\n})" } ] } }, - "/provider/{providerID}/oauth/callback": { + "/session/{sessionID}/summarize": { "post": { - "operationId": "provider.oauth.callback", + "tags": ["session"], + "operationId": "session.summarize", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "sessionID", "in": "path", - "name": "providerID", "schema": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, - "required": true, - "description": "Provider ID" + "required": true } ], - "summary": "OAuth callback", - "description": "Handle the OAuth callback from a provider after user authorization.", "responses": { "200": { - "description": "OAuth callback processed successfully", + "description": "Summarized session", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Summarized session" } } } @@ -5292,24 +5650,38 @@ } } } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } } }, + "description": "Generate a concise summary of the session using AI compaction to preserve key information.", + "summary": "Summarize session", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "method": { - "description": "Auth method index", - "type": "number" + "providerID": { + "type": "string" }, - "code": { - "description": "OAuth authorization code", + "modelID": { "type": "string" + }, + "auto": { + "type": "boolean" } }, - "required": ["method"] + "required": ["providerID", "modelID"], + "additionalProperties": false } } } @@ -5317,86 +5689,196 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.provider.oauth.callback({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.summarize({\n ...\n})" } ] } }, - "/sync/start": { + "/session/{sessionID}/prompt_async": { "post": { - "operationId": "sync.start", + "tags": ["session"], + "operationId": "session.prompt_async", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true } ], - "summary": "Start workspace sync", - "description": "Start sync loops for workspaces in the current project that have active sessions.", "responses": { - "200": { - "description": "Workspace sync started", - "content": { + "204": { + "description": "Prompt accepted" + }, + "400": { + "description": "Bad request", + "content": { "application/json": { "schema": { - "type": "boolean" + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } } }, + "description": "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + "summary": "Send async message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "agent": { + "type": "string" + }, + "noReply": { + "type": "boolean" + }, + "tools": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "format": { + "$ref": "#/components/schemas/OutputFormat" + }, + "system": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextPartInput" + }, + { + "$ref": "#/components/schemas/FilePartInput" + }, + { + "$ref": "#/components/schemas/AgentPartInput" + }, + { + "$ref": "#/components/schemas/SubtaskPartInput" + } + ] + } + } + }, + "required": ["parts"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.start({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.prompt_async({\n ...\n})" } ] } }, - "/sync/replay": { + "/session/{sessionID}/command": { "post": { - "operationId": "sync.replay", + "tags": ["session"], + "operationId": "session.command", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true } ], - "summary": "Replay sync events", - "description": "Validate and replay a complete sync event history.", "responses": { "200": { - "description": "Replayed sync events", + "description": "Created message", "content": { "application/json": { "schema": { "type": "object", + "required": ["info", "parts"], "properties": { - "sessionID": { - "type": "string" + "info": { + "$ref": "#/components/schemas/AssistantMessage" + }, + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } } - }, - "required": ["sessionID"] + } } } } @@ -5410,19 +5892,45 @@ } } } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } } }, + "description": "Send a new command to a session for execution by the AI assistant.", + "summary": "Send command", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { - "directory": { + "messageID": { "type": "string" }, - "events": { - "minItems": 1, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "arguments": { + "type": "string" + }, + "command": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "parts": { "type": "array", "items": { "type": "object", @@ -5430,30 +5938,30 @@ "id": { "type": "string" }, - "aggregateID": { + "type": { + "type": "string", + "enum": ["file"] + }, + "mime": { "type": "string" }, - "seq": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "filename": { + "type": "string" }, - "type": { + "url": { "type": "string" }, - "data": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "source": { + "$ref": "#/components/schemas/FilePartSource" } }, - "required": ["id", "aggregateID", "seq", "type", "data"] + "required": ["type", "mime", "url"], + "additionalProperties": false } } }, - "required": ["directory", "events"] + "required": ["arguments", "command"], + "additionalProperties": false } } } @@ -5461,64 +5969,63 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.replay({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.command({\n ...\n})" } ] } }, - "/sync/history": { + "/session/{sessionID}/shell": { "post": { - "operationId": "sync.history.list", + "tags": ["session"], + "operationId": "session.shell", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true } ], - "summary": "List sync events", - "description": "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", "responses": { "200": { - "description": "Sync events", + "description": "Created message", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "aggregate_id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "type": { - "type": "string" - }, - "data": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Message" }, - "required": ["id", "aggregate_id", "seq", "type", "data"] - } + "parts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Part" + } + } + }, + "required": ["info", "parts"], + "additionalProperties": false, + "description": "Created message" } } } @@ -5532,132 +6039,51 @@ } } } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } } }, + "description": "Execute a shell command within the session context and return the AI's response.", + "summary": "Run shell command", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.history.list({\n ...\n})" - } - ] - } - }, - "/find": { - "get": { - "operationId": "find.text", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "pattern", - "schema": { - "type": "string" - }, - "required": true - } - ], - "summary": "Find text", - "description": "Search for text patterns across files in the project using ripgrep.", - "responses": { - "200": { - "description": "Matches", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { + "properties": { + "messageID": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { "type": "object", "properties": { - "path": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "lines": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "line_number": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "absolute_offset": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "providerID": { + "type": "string" }, - "submatches": { - "type": "array", - "items": { - "type": "object", - "properties": { - "match": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": ["text"] - }, - "start": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["match", "start", "end"] - } + "modelID": { + "type": "string" } }, - "required": ["path", "lines", "line_number", "absolute_offset", "submatches"] + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "command": { + "type": "string" } - } + }, + "required": ["agent", "command"], + "additionalProperties": false } } } @@ -5665,76 +6091,91 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.text({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.shell({\n ...\n})" } ] } }, - "/find/file": { - "get": { - "operationId": "find.files", + "/session/{sessionID}/revert": { + "post": { + "tags": ["session"], + "operationId": "session.revert", "parameters": [ { - "in": "query", "name": "directory", - "schema": { - "type": "string" - } - }, - { "in": "query", - "name": "workspace", + "required": false, "schema": { "type": "string" } }, { + "name": "workspace", "in": "query", - "name": "query", + "required": false, "schema": { "type": "string" - }, - "required": true - }, - { - "in": "query", - "name": "dirs", - "schema": { - "type": "string", - "enum": ["true", "false"] } }, { - "in": "query", - "name": "type", + "name": "sessionID", + "in": "path", "schema": { "type": "string", - "enum": ["file", "directory"] - } - }, - { - "in": "query", - "name": "limit", - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200 - } + "pattern": "^ses.*" + }, + "required": true } ], - "summary": "Find files", - "description": "Search for files or directories by name or pattern in the project directory.", "responses": { "200": { - "description": "File paths", + "description": "Updated session", "content": { "application/json": { "schema": { - "type": "array", - "items": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Revert a specific message in a session, undoing its effects and restoring the previous state.", + "summary": "Revert message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "partID": { "type": "string" } - } + }, + "required": ["messageID"], + "additionalProperties": false } } } @@ -5742,328 +6183,576 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.files({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.revert({\n ...\n})" } ] } }, - "/find/symbol": { - "get": { - "operationId": "find.symbols", + "/session/{sessionID}/unrevert": { + "post": { + "tags": ["session"], + "operationId": "session.unrevert", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", - "name": "query", + "name": "sessionID", + "in": "path", "schema": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, "required": true } ], - "summary": "Find symbols", - "description": "Search for workspace symbols like functions, classes, and variables using LSP.", "responses": { "200": { - "description": "Symbols", + "description": "Updated session", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Symbol" - } + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } } }, + "description": "Restore all previously reverted messages in a session.", + "summary": "Restore reverted messages", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.find.symbols({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.session.unrevert({\n ...\n})" } ] } }, - "/file": { - "get": { - "operationId": "file.list", + "/session/{sessionID}/permissions/{permissionID}": { + "post": { + "tags": ["session"], + "operationId": "permission.respond", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", - "name": "path", + "name": "sessionID", + "in": "path", "schema": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, "required": true - } - ], - "summary": "List files", - "description": "List files and directories in a specified path.", + }, + { + "name": "permissionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^per.*" + }, + "required": true + } + ], "responses": { "200": { - "description": "Files and directories", + "description": "Permission processed successfully", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FileNode" - } + "type": "boolean", + "description": "Permission processed successfully" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } } }, + "description": "Approve or deny a permission request from the AI assistant.", + "summary": "Respond to permission", + "deprecated": true, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "response": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["response"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.permission.respond({\n ...\n})" } ] } }, - "/file/content": { - "get": { - "operationId": "file.read", + "/session/{sessionID}/message/{messageID}/part/{partID}": { + "delete": { + "tags": ["session"], + "operationId": "part.delete", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", - "name": "path", + "name": "sessionID", + "in": "path", "schema": { - "type": "string" + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": true + }, + { + "name": "partID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^prt.*" }, "required": true } ], - "summary": "Read file", - "description": "Read the content of a specified file.", "responses": { "200": { - "description": "File content", + "description": "Successfully deleted part", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileContent" + "type": "boolean", + "description": "Successfully deleted part" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } } }, + "description": "Delete a part from a message.", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.read({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.delete({\n ...\n})" } ] - } - }, - "/file/status": { - "get": { - "operationId": "file.status", + }, + "patch": { + "tags": ["session"], + "operationId": "part.update", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses.*" + }, + "required": true + }, + { + "name": "messageID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^msg.*" + }, + "required": true + }, + { + "name": "partID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^prt.*" + }, + "required": true } ], - "summary": "Get file status", - "description": "Get the git status of all files in the project.", "responses": { "200": { - "description": "File status", + "description": "Successfully updated part", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/File" - } + "$ref": "#/components/schemas/Part" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" } } } } }, + "description": "Update a part in a message.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Part" + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.file.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.part.update({\n ...\n})" } ] } }, - "/event": { - "get": { - "operationId": "event.subscribe", + "/sync/start": { + "post": { + "tags": ["sync"], + "operationId": "sync.start", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Subscribe to events", - "description": "Get events", "responses": { "200": { - "description": "Event stream", + "description": "Workspace sync started", "content": { - "text/event-stream": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Event" + "type": "boolean", + "description": "Workspace sync started" } } } } }, + "description": "Start sync loops for workspaces in the current project that have active sessions.", + "summary": "Start workspace sync", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.start({\n ...\n})" } ] } }, - "/mcp": { - "get": { - "operationId": "mcp.status", + "/sync/replay": { + "post": { + "tags": ["sync"], + "operationId": "sync.replay", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get MCP status", - "description": "Get the status of all Model Context Protocol (MCP) servers.", "responses": { "200": { - "description": "MCP server status", + "description": "Replayed sync events", "content": { "application/json": { "schema": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "sessionID": { + "type": "string" + } }, - "additionalProperties": { - "$ref": "#/components/schemas/MCPStatus" - } + "required": ["sessionID"], + "additionalProperties": false, + "description": "Replayed sync events" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" } } } } }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.status({\n ...\n})" + "description": "Validate and replay a complete sync event history.", + "summary": "Replay sync events", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "events": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "minimum": 0 + }, + "type": { + "type": "string" + }, + "data": { + "type": "object" + } + }, + "required": ["id", "aggregateID", "seq", "type", "data"], + "additionalProperties": false + } + } + }, + "required": ["directory", "events"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.replay({\n ...\n})" } ] - }, + } + }, + "/sync/history": { "post": { - "operationId": "mcp.add", + "tags": ["sync"], + "operationId": "sync.history.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Add MCP server", - "description": "Dynamically add a new Model Context Protocol (MCP) server to the system.", "responses": { "200": { - "description": "MCP server added successfully", + "description": "Sync events", "content": { "application/json": { "schema": { - "type": "object", - "propertyNames": { - "type": "string" + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "aggregate_id": { + "type": "string" + }, + "seq": { + "type": "integer", + "minimum": 0 + }, + "type": { + "type": "string" + }, + "data": { + "type": "object" + } + }, + "required": ["id", "aggregate_id", "seq", "type", "data"], + "additionalProperties": false }, - "additionalProperties": { - "$ref": "#/components/schemas/MCPStatus" - } + "description": "Sync events" } } } @@ -6079,27 +6768,17 @@ } } }, + "description": "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", + "summary": "List sync events", "requestBody": { "content": { "application/json": { "schema": { "type": "object", - "properties": { - "name": { - "type": "string" - }, - "config": { - "anyOf": [ - { - "$ref": "#/components/schemas/McpLocalConfig" - }, - { - "$ref": "#/components/schemas/McpRemoteConfig" - } - ] - } - }, - "required": ["name", "config"] + "additionalProperties": { + "type": "integer", + "minimum": 0 + } } } } @@ -6107,139 +6786,125 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.add({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.history.list({\n ...\n})" } ] } }, - "/mcp/{name}/auth": { - "post": { - "operationId": "mcp.auth.start", + "/api/session": { + "get": { + "tags": ["v2"], + "operationId": "v2.session.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } - }, - { - "schema": { - "type": "string" - }, - "in": "path", - "name": "name", - "required": true } ], - "summary": "Start MCP OAuth", - "description": "Start OAuth authentication flow for a Model Context Protocol (MCP) server.", "responses": { "200": { - "description": "OAuth flow started", + "description": "V2SessionsResponse", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "authorizationUrl": { - "description": "URL to open in browser for authorization", - "type": "string" - } - }, - "required": ["authorizationUrl"] + "$ref": "#/components/schemas/V2SessionsResponse" } } } }, "400": { - "description": "MCP server does not support OAuth", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/McpUnsupportedOAuthError" - } - } - } - }, - "404": { - "description": "Not found", + "description": "Bad request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundError" + "$ref": "#/components/schemas/BadRequestError" } } } } }, + "description": "Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.", + "summary": "List v2 sessions", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.start({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.list({\n ...\n})" } ] - }, - "delete": { - "operationId": "mcp.auth.remove", + } + }, + "/api/session/{sessionID}/prompt": { + "post": { + "tags": ["v2"], + "operationId": "v2.session.prompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "sessionID", + "in": "path", "schema": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, - "in": "path", - "name": "name", "required": true } ], - "summary": "Remove MCP OAuth", - "description": "Remove OAuth credentials for an MCP server", "responses": { "200": { - "description": "OAuth credentials removed", + "description": "Session.Message", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "const": true - } - }, - "required": ["success"] + "$ref": "#/components/schemas/SessionMessage" } } } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } + } + }, + "description": "Create a v2 session message and queue it for the agent loop.", + "summary": "Send v2 message", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "prompt": { + "$ref": "#/components/schemas/Prompt" + }, + "delivery": { + "$ref": "#/components/schemas/SessionDelivery" + } + }, + "required": ["prompt"], + "additionalProperties": false } } } @@ -6247,289 +6912,252 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.remove({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.prompt({\n ...\n})" } ] } }, - "/mcp/{name}/auth/callback": { + "/api/session/{sessionID}/compact": { "post": { - "operationId": "mcp.auth.callback", + "tags": ["v2"], + "operationId": "v2.session.compact", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "sessionID", + "in": "path", "schema": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, - "in": "path", - "name": "name", "required": true } ], - "summary": "Complete MCP OAuth", - "description": "Complete OAuth authentication for a Model Context Protocol (MCP) server using the authorization code.", "responses": { - "200": { - "description": "OAuth authentication completed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MCPStatus" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "code": { - "description": "Authorization code from OAuth callback", - "type": "string" - } - }, - "required": ["code"] - } - } + "204": { + "description": "" } }, + "description": "Compact a v2 session conversation.", + "summary": "Compact v2 session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.callback({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.compact({\n ...\n})" } ] } }, - "/mcp/{name}/auth/authenticate": { + "/api/session/{sessionID}/wait": { "post": { - "operationId": "mcp.auth.authenticate", + "tags": ["v2"], + "operationId": "v2.session.wait", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "sessionID", + "in": "path", "schema": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, - "in": "path", - "name": "name", "required": true } ], - "summary": "Authenticate MCP OAuth", - "description": "Start OAuth flow and wait for callback (opens browser)", "responses": { - "200": { - "description": "OAuth authentication completed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MCPStatus" - } - } - } - }, - "400": { - "description": "MCP server does not support OAuth", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/McpUnsupportedOAuthError" - } - } - } - }, - "404": { - "description": "Not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } + "204": { + "description": "" } }, + "description": "Wait for a v2 session agent loop to become idle.", + "summary": "Wait for v2 session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.auth.authenticate({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.wait({\n ...\n})" } ] } }, - "/mcp/{name}/connect": { - "post": { - "operationId": "mcp.connect", + "/api/session/{sessionID}/context": { + "get": { + "tags": ["v2"], + "operationId": "v2.session.context", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "sessionID", "in": "path", - "name": "name", "schema": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, "required": true } ], - "description": "Connect an MCP server", "responses": { "200": { - "description": "MCP server connected successfully", + "description": "Success", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionMessage" + } } } } } }, + "description": "Retrieve the active context messages for a v2 session (all messages after the last compaction).", + "summary": "Get v2 session context", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.connect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.context({\n ...\n})" } ] } }, - "/mcp/{name}/disconnect": { - "post": { - "operationId": "mcp.disconnect", + "/api/session/{sessionID}/message": { + "get": { + "tags": ["v2 messages"], + "operationId": "v2.session.messages", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } }, { + "name": "sessionID", "in": "path", - "name": "name", "schema": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, "required": true } ], - "description": "Disconnect an MCP server", "responses": { "200": { - "description": "MCP server disconnected successfully", + "description": "V2SessionMessagesResponse", "content": { "application/json": { "schema": { - "type": "boolean" + "$ref": "#/components/schemas/V2SessionMessagesResponse" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" } } } } }, + "description": "Retrieve projected v2 messages for a session. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline.", + "summary": "Get v2 session messages", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.mcp.disconnect({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.messages({\n ...\n})" } ] } }, "/tui/append-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.appendPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Append TUI prompt", - "description": "Append prompt to the TUI", "responses": { "200": { "description": "Prompt processed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt processed successfully" } } } @@ -6545,6 +7173,8 @@ } } }, + "description": "Append prompt to the TUI.", + "summary": "Append TUI prompt", "requestBody": { "content": { "application/json": { @@ -6555,7 +7185,8 @@ "type": "string" } }, - "required": ["text"] + "required": ["text"], + "additionalProperties": false } } } @@ -6570,37 +7201,41 @@ }, "/tui/open-help": { "post": { + "tags": ["tui"], "operationId": "tui.openHelp", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open help dialog", - "description": "Open the help dialog in the TUI to display user assistance information.", "responses": { "200": { "description": "Help dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Help dialog opened successfully" } } } } }, + "description": "Open the help dialog in the TUI to display user assistance information.", + "summary": "Open help dialog", "x-codeSamples": [ { "lang": "js", @@ -6611,37 +7246,41 @@ }, "/tui/open-sessions": { "post": { + "tags": ["tui"], "operationId": "tui.openSessions", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open sessions dialog", - "description": "Open the session dialog", "responses": { "200": { "description": "Session dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Session dialog opened successfully" } } } } }, + "description": "Open the session dialog.", + "summary": "Open sessions dialog", "x-codeSamples": [ { "lang": "js", @@ -6652,37 +7291,41 @@ }, "/tui/open-themes": { "post": { + "tags": ["tui"], "operationId": "tui.openThemes", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open themes dialog", - "description": "Open the theme dialog", "responses": { "200": { "description": "Theme dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Theme dialog opened successfully" } } } } }, + "description": "Open the theme dialog.", + "summary": "Open themes dialog", "x-codeSamples": [ { "lang": "js", @@ -6693,37 +7336,41 @@ }, "/tui/open-models": { "post": { + "tags": ["tui"], "operationId": "tui.openModels", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Open models dialog", - "description": "Open the model dialog", "responses": { "200": { "description": "Model dialog opened successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Model dialog opened successfully" } } } } }, + "description": "Open the model dialog.", + "summary": "Open models dialog", "x-codeSamples": [ { "lang": "js", @@ -6734,37 +7381,41 @@ }, "/tui/submit-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.submitPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Submit TUI prompt", - "description": "Submit the prompt", "responses": { "200": { "description": "Prompt submitted successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt submitted successfully" } } } } }, + "description": "Submit the prompt.", + "summary": "Submit TUI prompt", "x-codeSamples": [ { "lang": "js", @@ -6775,37 +7426,41 @@ }, "/tui/clear-prompt": { "post": { + "tags": ["tui"], "operationId": "tui.clearPrompt", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Clear TUI prompt", - "description": "Clear the prompt", "responses": { "200": { "description": "Prompt cleared successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Prompt cleared successfully" } } } } }, + "description": "Clear the prompt.", + "summary": "Clear TUI prompt", "x-codeSamples": [ { "lang": "js", @@ -6816,32 +7471,34 @@ }, "/tui/execute-command": { "post": { + "tags": ["tui"], "operationId": "tui.executeCommand", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Execute TUI command", - "description": "Execute a TUI command (e.g. agent_cycle)", "responses": { "200": { "description": "Command executed successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Command executed successfully" } } } @@ -6857,6 +7514,8 @@ } } }, + "description": "Execute a TUI command.", + "summary": "Execute TUI command", "requestBody": { "content": { "application/json": { @@ -6867,7 +7526,8 @@ "type": "string" } }, - "required": ["command"] + "required": ["command"], + "additionalProperties": false } } } @@ -6882,37 +7542,41 @@ }, "/tui/show-toast": { "post": { + "tags": ["tui"], "operationId": "tui.showToast", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Show TUI toast", - "description": "Show a toast notification in the TUI", "responses": { "200": { "description": "Toast notification shown successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Toast notification shown successfully" } } } } }, + "description": "Show a toast notification in the TUI.", + "summary": "Show TUI toast", "requestBody": { "content": { "application/json": { @@ -6930,13 +7594,12 @@ "enum": ["info", "success", "warning", "error"] }, "duration": { - "description": "Duration in milliseconds", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["message", "variant"] + "required": ["message", "variant"], + "additionalProperties": false } } } @@ -6951,32 +7614,34 @@ }, "/tui/publish": { "post": { + "tags": ["tui"], "operationId": "tui.publish", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Publish TUI event", - "description": "Publish a TUI event", "responses": { "200": { "description": "Event published successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Event published successfully" } } } @@ -6992,22 +7657,24 @@ } } }, + "description": "Publish a TUI event.", + "summary": "Publish TUI event", "requestBody": { "content": { "application/json": { "schema": { "anyOf": [ { - "$ref": "#/components/schemas/Event.tui.prompt.append" + "$ref": "#/components/schemas/EventTuiPromptAppend" }, { - "$ref": "#/components/schemas/Event.tui.command.execute" + "$ref": "#/components/schemas/EventTuiCommandExecute" }, { - "$ref": "#/components/schemas/Event.tui.toast.show" + "$ref": "#/components/schemas/EventTuiToastShow" }, { - "$ref": "#/components/schemas/Event.tui.session.select" + "$ref": "#/components/schemas/EventTuiSessionSelect" } ] } @@ -7024,32 +7691,34 @@ }, "/tui/select-session": { "post": { + "tags": ["tui"], "operationId": "tui.selectSession", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Select session", - "description": "Navigate the TUI to display the specified session.", "responses": { "200": { "description": "Session selected successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Session selected successfully" } } } @@ -7075,6 +7744,8 @@ } } }, + "description": "Navigate the TUI to display the specified session.", + "summary": "Select session", "requestBody": { "content": { "application/json": { @@ -7082,12 +7753,12 @@ "type": "object", "properties": { "sessionID": { - "description": "Session ID to navigate to", "type": "string", - "pattern": "^ses.*" + "description": "Session ID to navigate to" } }, - "required": ["sessionID"] + "required": ["sessionID"], + "additionalProperties": false } } } @@ -7102,25 +7773,26 @@ }, "/tui/control/next": { "get": { + "tags": ["tui"], "operationId": "tui.control.next", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get next TUI request", - "description": "Retrieve the next TUI (Terminal User Interface) request from the queue for processing.", "responses": { "200": { "description": "Next TUI request", @@ -7134,12 +7806,16 @@ }, "body": {} }, - "required": ["path", "body"] + "required": ["path", "body"], + "additionalProperties": false, + "description": "Next TUI request" } } } } }, + "description": "Retrieve the next TUI request from the queue for processing.", + "summary": "Get next TUI request", "x-codeSamples": [ { "lang": "js", @@ -7150,37 +7826,41 @@ }, "/tui/control/response": { "post": { + "tags": ["tui"], "operationId": "tui.control.response", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Submit TUI response", - "description": "Submit a response to the TUI request queue to complete a pending request.", "responses": { "200": { "description": "Response submitted successfully", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "boolean", + "description": "Response submitted successfully" } } } } }, + "description": "Submit a response to the TUI request queue to complete a pending request.", + "summary": "Submit TUI response", "requestBody": { "content": { "application/json": { @@ -7196,413 +7876,474 @@ ] } }, - "/instance/dispose": { - "post": { - "operationId": "instance.dispose", + "/experimental/workspace/adapter": { + "get": { + "tags": ["workspace"], + "operationId": "experimental.workspace.adapter.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Dispose instance", - "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", "responses": { "200": { - "description": "Instance disposed", + "description": "Workspace adapters", "content": { "application/json": { "schema": { - "type": "boolean" + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["type", "name", "description"], + "additionalProperties": false + }, + "description": "Workspace adapters" } } } } }, + "description": "List all available workspace adapters for the current project.", + "summary": "List workspace adapters", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.instance.dispose({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.adapter.list({\n ...\n})" } ] } }, - "/path": { + "/experimental/workspace": { "get": { - "operationId": "path.get", + "tags": ["workspace"], + "operationId": "experimental.workspace.list", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get paths", - "description": "Retrieve the current working directory and related path information for the OpenCode instance.", "responses": { "200": { - "description": "Path", + "description": "Workspaces", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Path" + "type": "array", + "items": { + "$ref": "#/components/schemas/Workspace" + }, + "description": "Workspaces" } } } } }, + "description": "List all workspaces.", + "summary": "List workspaces", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.path.get({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.list({\n ...\n})" } ] - } - }, - "/vcs": { - "get": { - "operationId": "vcs.get", + }, + "post": { + "tags": ["workspace"], + "operationId": "experimental.workspace.create", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "Get VCS info", - "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", "responses": { "200": { - "description": "VCS info", + "description": "Workspace created", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/VcsInfo" + "$ref": "#/components/schemas/Workspace" } } } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.get({\n ...\n})" - } - ] - } - }, - "/vcs/diff": { - "get": { - "operationId": "vcs.diff", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } }, - { - "in": "query", - "name": "mode", - "schema": { - "type": "string", - "enum": ["git", "branch"] - }, - "required": true - } - ], - "summary": "Get VCS diff", - "description": "Retrieve the current git diff for the working tree or against the default branch.", - "responses": { - "200": { - "description": "VCS diff", + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/VcsFileDiff" - } + "$ref": "#/components/schemas/BadRequestError" } } } } }, + "description": "Create a workspace for the current project.", + "summary": "Create workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": ["type", "branch"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.diff({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.create({\n ...\n})" } ] } }, - "/command": { + "/experimental/workspace/status": { "get": { - "operationId": "command.list", + "tags": ["workspace"], + "operationId": "experimental.workspace.status", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } } ], - "summary": "List commands", - "description": "Get a list of all available commands in the OpenCode system.", "responses": { "200": { - "description": "List of commands", + "description": "Workspace status", "content": { "application/json": { "schema": { "type": "array", "items": { - "$ref": "#/components/schemas/Command" - } + "type": "object", + "properties": { + "workspaceID": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + }, + "description": "Workspace status" } } } } }, + "description": "Get connection status for workspaces in the current project.", + "summary": "Workspace status", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.command.list({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.status({\n ...\n})" } ] } }, - "/agent": { - "get": { - "operationId": "app.agents", + "/experimental/workspace/{id}": { + "delete": { + "tags": ["workspace"], + "operationId": "experimental.workspace.remove", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true } ], - "summary": "List agents", - "description": "Get a list of all available AI agents in the OpenCode system.", "responses": { "200": { - "description": "List of agents", + "description": "Workspace removed", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Agent" - } + "$ref": "#/components/schemas/Workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" } } } } }, + "description": "Remove an existing workspace.", + "summary": "Remove workspace", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.agents({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.remove({\n ...\n})" } ] } }, - "/skill": { - "get": { - "operationId": "app.skills", + "/experimental/workspace/{id}/session-restore": { + "post": { + "tags": ["workspace"], + "operationId": "experimental.workspace.sessionRestore", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "id", + "in": "path", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true } ], - "summary": "List skills", - "description": "Get a list of all available skills in the OpenCode system.", "responses": { "200": { - "description": "List of skills", + "description": "Session replay started", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "location": { - "type": "string" - }, - "content": { - "type": "string" - } - }, - "required": ["name", "description", "location", "content"] - } + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["total"], + "additionalProperties": false, + "description": "Session replay started" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" } } } } }, + "description": "Replay a session's sync events into the target workspace in batches.", + "summary": "Restore session into workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + } + } + }, "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.app.skills({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" } ] } }, - "/lsp": { + "/pty/{ptyID}/connect": { "get": { - "operationId": "lsp.status", + "tags": ["pty"], + "operationId": "pty.connect", "parameters": [ { - "in": "query", "name": "directory", + "in": "query", + "required": false, "schema": { "type": "string" } }, { - "in": "query", "name": "workspace", + "in": "query", + "required": false, "schema": { "type": "string" } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true } ], - "summary": "Get LSP status", - "description": "Get LSP server status", "responses": { "200": { - "description": "LSP server status", + "description": "Connected session", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LSPStatus" - } + "type": "boolean", + "description": "Connected session" } } } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.lsp.status({\n ...\n})" - } - ] - } - }, - "/formatter": { - "get": { - "operationId": "formatter.status", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Get formatter status", - "description": "Get formatter status", - "responses": { - "200": { - "description": "Formatter status", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FormatterStatus" - } + "$ref": "#/components/schemas/NotFoundError" } } } } }, + "description": "Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.", + "summary": "Connect to PTY session", "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.formatter.status({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connect({\n ...\n})" } ] } @@ -7610,165 +8351,311 @@ }, "components": { "schemas": { - "Event.server.instance.disposed": { - "type": "object", - "properties": { - "id": { - "type": "string" + "Event": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" }, - "type": { - "type": "string", - "const": "server.instance.disposed" + { + "$ref": "#/components/schemas/EventFileEdited" }, - "properties": { - "type": "object", - "properties": { - "directory": { - "type": "string" - } - }, - "required": ["directory"] + { + "$ref": "#/components/schemas/EventFileWatcherUpdated" + }, + { + "$ref": "#/components/schemas/EventLspClientDiagnostics" + }, + { + "$ref": "#/components/schemas/EventLspUpdated" + }, + { + "$ref": "#/components/schemas/EventMessagePartDelta" + }, + { + "$ref": "#/components/schemas/EventPermissionAsked" + }, + { + "$ref": "#/components/schemas/EventPermissionReplied" + }, + { + "$ref": "#/components/schemas/EventSessionDiff" + }, + { + "$ref": "#/components/schemas/EventSessionError" + }, + { + "$ref": "#/components/schemas/EventInstallationUpdated" + }, + { + "$ref": "#/components/schemas/EventInstallationUpdate-available" + }, + { + "$ref": "#/components/schemas/EventQuestionAsked" + }, + { + "$ref": "#/components/schemas/EventQuestionReplied" + }, + { + "$ref": "#/components/schemas/EventQuestionRejected" + }, + { + "$ref": "#/components/schemas/EventTodoUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionStatus" + }, + { + "$ref": "#/components/schemas/EventSessionIdle" + }, + { + "$ref": "#/components/schemas/EventSessionCompacted" + }, + { + "$ref": "#/components/schemas/Event.tui.prompt.append" + }, + { + "$ref": "#/components/schemas/Event.tui.command.execute" + }, + { + "$ref": "#/components/schemas/EventTuiToastShow1" + }, + { + "$ref": "#/components/schemas/Event.tui.session.select" + }, + { + "$ref": "#/components/schemas/EventMcpToolsChanged" + }, + { + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" + }, + { + "$ref": "#/components/schemas/EventCommandExecuted" + }, + { + "$ref": "#/components/schemas/EventProjectUpdated" + }, + { + "$ref": "#/components/schemas/EventVcsBranchUpdated" + }, + { + "$ref": "#/components/schemas/EventWorkspaceReady" + }, + { + "$ref": "#/components/schemas/EventWorkspaceFailed" + }, + { + "$ref": "#/components/schemas/EventWorkspaceRestore" + }, + { + "$ref": "#/components/schemas/EventWorkspaceStatus" + }, + { + "$ref": "#/components/schemas/EventWorktreeReady" + }, + { + "$ref": "#/components/schemas/EventWorktreeFailed" + }, + { + "$ref": "#/components/schemas/EventPtyCreated" + }, + { + "$ref": "#/components/schemas/EventPtyUpdated" + }, + { + "$ref": "#/components/schemas/EventPtyExited" + }, + { + "$ref": "#/components/schemas/EventPtyDeleted" + }, + { + "$ref": "#/components/schemas/EventMessageUpdated" + }, + { + "$ref": "#/components/schemas/EventMessageRemoved" + }, + { + "$ref": "#/components/schemas/EventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/EventMessagePartRemoved" + }, + { + "$ref": "#/components/schemas/EventSessionCreated" + }, + { + "$ref": "#/components/schemas/EventSessionUpdated" + }, + { + "$ref": "#/components/schemas/EventSessionDeleted" + }, + { + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/EventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/EventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolError" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" } - }, - "required": ["id", "type", "properties"] + ] }, - "Event.file.edited": { + "OAuth": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "const": "file.edited" + "enum": ["oauth"] }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - } - }, - "required": ["file"] + "refresh": { + "type": "string" + }, + "access": { + "type": "string" + }, + "expires": { + "type": "integer", + "minimum": 0 + }, + "accountId": { + "type": "string" + }, + "enterpriseUrl": { + "type": "string" } }, - "required": ["id", "type", "properties"] + "required": ["type", "refresh", "access", "expires"], + "additionalProperties": false }, - "Event.file.watcher.updated": { + "ApiAuth": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "const": "file.watcher.updated" + "enum": ["api"] }, - "properties": { + "key": { + "type": "string" + }, + "metadata": { "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "type": "string", - "enum": ["add", "change", "unlink"] - } - }, - "required": ["file", "event"] + "additionalProperties": { + "type": "string" + } } }, - "required": ["id", "type", "properties"] + "required": ["type", "key"], + "additionalProperties": false }, - "Event.lsp.client.diagnostics": { + "WellKnownAuth": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "const": "lsp.client.diagnostics" + "enum": ["wellknown"] }, - "properties": { - "type": "object", - "properties": { - "serverID": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "required": ["serverID", "path"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.lsp.updated": { - "type": "object", - "properties": { - "id": { + "key": { "type": "string" }, - "type": { - "type": "string", - "const": "lsp.updated" - }, - "properties": { - "type": "object", - "properties": {} + "token": { + "type": "string" } }, - "required": ["id", "type", "properties"] + "required": ["type", "key", "token"], + "additionalProperties": false }, - "Event.message.part.delta": { - "type": "object", - "properties": { - "id": { - "type": "string" + "Auth": { + "anyOf": [ + { + "$ref": "#/components/schemas/OAuth" }, - "type": { - "type": "string", - "const": "message.part.delta" + { + "$ref": "#/components/schemas/ApiAuth" }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - }, - "field": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["sessionID", "messageID", "partID", "field", "delta"] + { + "$ref": "#/components/schemas/WellKnownAuth" } - }, - "required": ["id", "type", "properties"] + ] }, "PermissionRequest": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^per.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "permission": { "type": "string" @@ -7780,11 +8667,7 @@ } }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "always": { "type": "array", @@ -7796,64 +8679,18 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "callID": { "type": "string" } }, - "required": ["messageID", "callID"] - } - }, - "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"] - }, - "Event.permission.asked": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "permission.asked" - }, - "properties": { - "$ref": "#/components/schemas/PermissionRequest" - } - }, - "required": ["id", "type", "properties"] - }, - "Event.permission.replied": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "permission.replied" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^per.*" - }, - "reply": { - "type": "string", - "enum": ["once", "always", "reject"] - } - }, - "required": ["sessionID", "requestID", "reply"] + "required": ["messageID", "callID"], + "additionalProperties": false } }, - "required": ["id", "type", "properties"] + "required": ["id", "sessionID", "permission", "patterns", "metadata", "always"], + "additionalProperties": false }, "SnapshotFileDiff": { "type": "object", @@ -7866,56 +8703,26 @@ }, "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "status": { "type": "string", "enum": ["added", "deleted", "modified"] } }, - "required": ["file", "patch", "additions", "deletions"] - }, - "Event.session.diff": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.diff" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "diff": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } - } - }, - "required": ["sessionID", "diff"] - } - }, - "required": ["id", "type", "properties"] + "required": ["file", "patch", "additions", "deletions"], + "additionalProperties": false }, "ProviderAuthError": { "type": "object", "properties": { "name": { "type": "string", - "const": "ProviderAuthError" + "enum": ["ProviderAuthError"] }, "data": { "type": "object", @@ -7927,17 +8734,19 @@ "type": "string" } }, - "required": ["providerID", "message"] + "required": ["providerID", "message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "UnknownError": { "type": "object", "properties": { "name": { "type": "string", - "const": "UnknownError" + "enum": ["UnknownError"] }, "data": { "type": "object", @@ -7946,31 +8755,34 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "MessageOutputLengthError": { "type": "object", "properties": { "name": { "type": "string", - "const": "MessageOutputLengthError" + "enum": ["MessageOutputLengthError"] }, "data": { "type": "object", "properties": {} } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "MessageAbortedError": { "type": "object", "properties": { "name": { "type": "string", - "const": "MessageAbortedError" + "enum": ["MessageAbortedError"] }, "data": { "type": "object", @@ -7979,17 +8791,19 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "StructuredOutputError": { "type": "object", "properties": { "name": { "type": "string", - "const": "StructuredOutputError" + "enum": ["StructuredOutputError"] }, "data": { "type": "object", @@ -7999,21 +8813,22 @@ }, "retries": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["message", "retries"] + "required": ["message", "retries"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "ContextOverflowError": { "type": "object", "properties": { "name": { "type": "string", - "const": "ContextOverflowError" + "enum": ["ContextOverflowError"] }, "data": { "type": "object", @@ -8025,17 +8840,19 @@ "type": "string" } }, - "required": ["message"] + "required": ["message"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["name", "data"], + "additionalProperties": false }, "APIError": { "type": "object", "properties": { "name": { "type": "string", - "const": "APIError" + "enum": ["APIError"] }, "data": { "type": "object", @@ -8045,17 +8862,13 @@ }, "statusCode": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "isRetryable": { "type": "boolean" }, "responseHeaders": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } @@ -8065,222 +8878,111 @@ }, "metadata": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "string" } } }, - "required": ["message", "isRetryable"] - } - }, - "required": ["name", "data"] - }, - "Event.session.error": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.error" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "error": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] - } - } - } - }, - "required": ["id", "type", "properties"] - }, - "Event.installation.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "installation.updated" - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.installation.update-available": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "installation.update-available" - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"] + "required": ["message", "isRetryable"], + "additionalProperties": false } }, - "required": ["id", "type", "properties"] + "required": ["name", "data"], + "additionalProperties": false }, "QuestionOption": { "type": "object", "properties": { "label": { - "description": "Display text (1-5 words, concise)", - "type": "string" + "type": "string", + "description": "Display text (1-5 words, concise)" }, "description": { - "description": "Explanation of choice", - "type": "string" + "type": "string", + "description": "Explanation of choice" } }, - "required": ["label", "description"] + "required": ["label", "description"], + "additionalProperties": false }, "QuestionInfo": { "type": "object", "properties": { "question": { - "description": "Complete question", - "type": "string" + "type": "string", + "description": "Complete question" }, "header": { - "description": "Very short label (max 30 chars)", - "type": "string" + "type": "string", + "description": "Very short label (max 30 chars)" }, "options": { - "description": "Available choices", "type": "array", "items": { "$ref": "#/components/schemas/QuestionOption" - } + }, + "description": "Available choices" }, "multiple": { - "description": "Allow selecting multiple choices", "type": "boolean" }, "custom": { - "description": "Allow typing a custom answer (default: true)", "type": "boolean" } }, - "required": ["question", "header", "options"] + "required": ["question", "header", "options"], + "additionalProperties": false }, "QuestionTool": { "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "callID": { "type": "string" } }, - "required": ["messageID", "callID"] + "required": ["messageID", "callID"], + "additionalProperties": false }, "QuestionRequest": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^que.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "questions": { - "description": "Questions to ask", "type": "array", "items": { "$ref": "#/components/schemas/QuestionInfo" - } + }, + "description": "Questions to ask" }, "tool": { "$ref": "#/components/schemas/QuestionTool" } }, - "required": ["id", "sessionID", "questions"] + "required": ["id", "sessionID", "questions"], + "additionalProperties": false + }, + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } }, - "Event.question.asked": { + "QuestionReplied": { "type": "object", "properties": { - "id": { + "sessionID": { "type": "string" }, - "type": { - "type": "string", - "const": "question.asked" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" - } - }, - "required": ["id", "type", "properties"] - }, - "QuestionAnswer": { - "type": "array", - "items": { - "type": "string" - } - }, - "QuestionReplied": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, "requestID": { - "type": "string", - "pattern": "^que.*" + "type": "string" }, "answers": { "type": "array", @@ -8289,100 +8991,40 @@ } } }, - "required": ["sessionID", "requestID", "answers"] - }, - "Event.question.replied": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "question.replied" - }, - "properties": { - "$ref": "#/components/schemas/QuestionReplied" - } - }, - "required": ["id", "type", "properties"] + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false }, "QuestionRejected": { "type": "object", "properties": { "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "requestID": { - "type": "string", - "pattern": "^que.*" - } - }, - "required": ["sessionID", "requestID"] - }, - "Event.question.rejected": { - "type": "object", - "properties": { - "id": { "type": "string" - }, - "type": { - "type": "string", - "const": "question.rejected" - }, - "properties": { - "$ref": "#/components/schemas/QuestionRejected" } }, - "required": ["id", "type", "properties"] + "required": ["sessionID", "requestID"], + "additionalProperties": false }, "Todo": { "type": "object", "properties": { "content": { - "description": "Brief description of the task", - "type": "string" + "type": "string", + "description": "Brief description of the task" }, "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" + "type": "string", + "description": "Current status of the task: pending, in_progress, completed, cancelled" }, "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - } - }, - "required": ["content", "status", "priority"] - }, - "Event.todo.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { "type": "string", - "const": "todo.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"] + "description": "Priority level of the task: high, medium, low" } }, - "required": ["id", "type", "properties"] + "required": ["content", "status", "priority"], + "additionalProperties": false }, "SessionStatus": { "anyOf": [ @@ -8391,96 +9033,48 @@ "properties": { "type": { "type": "string", - "const": "idle" + "enum": ["idle"] } }, - "required": ["type"] + "required": ["type"], + "additionalProperties": false }, { "type": "object", "properties": { "type": { "type": "string", - "const": "retry" + "enum": ["retry"] }, "attempt": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "message": { "type": "string" }, "next": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["type", "attempt", "message", "next"] + "required": ["type", "attempt", "message", "next"], + "additionalProperties": false }, { "type": "object", "properties": { "type": { "type": "string", - "const": "busy" + "enum": ["busy"] } }, - "required": ["type"] + "required": ["type"], + "additionalProperties": false } ] }, - "Event.session.status": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.status" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.idle": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.idle" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.compacted": { + "Event.tui.prompt.append": { "type": "object", "properties": { "id": { @@ -8488,27 +9082,7 @@ }, "type": { "type": "string", - "const": "session.compacted" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.tui.prompt.append": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.prompt.append" + "enum": ["tui.prompt.append"] }, "properties": { "type": "object", @@ -8517,17 +9091,22 @@ "type": "string" } }, - "required": ["text"] + "required": ["text"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.command.execute": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.command.execute" + "enum": ["tui.command.execute"] }, "properties": { "type": "object", @@ -8561,17 +9140,22 @@ ] } }, - "required": ["command"] + "required": ["command"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.toast.show": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "tui.toast.show" + "enum": ["tui.toast.show"] }, "properties": { "type": "object", @@ -8587,86 +9171,18 @@ "enum": ["info", "success", "warning", "error"] }, "duration": { - "description": "Duration in milliseconds", "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "exclusiveMinimum": 0 } }, - "required": ["message", "variant"] + "required": ["message", "variant"], + "additionalProperties": false } }, - "required": ["type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Event.tui.session.select": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "tui.session.select" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "description": "Session ID to navigate to", - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "Event.mcp.tools.changed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "mcp.tools.changed" - }, - "properties": { - "type": "object", - "properties": { - "server": { - "type": "string" - } - }, - "required": ["server"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.mcp.browser.open.failed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "mcp.browser.open.failed" - }, - "properties": { - "type": "object", - "properties": { - "mcpName": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": ["mcpName", "url"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.command.executed": { "type": "object", "properties": { "id": { @@ -8674,30 +9190,22 @@ }, "type": { "type": "string", - "const": "command.executed" + "enum": ["tui.session.select"] }, "properties": { "type": "object", "properties": { - "name": { - "type": "string" - }, "sessionID": { "type": "string", - "pattern": "^ses.*" - }, - "arguments": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" + "description": "Session ID to navigate to" } }, - "required": ["name", "sessionID", "arguments", "messageID"] + "required": ["sessionID"], + "additionalProperties": false } }, - "required": ["id", "type", "properties"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, "Project": { "type": "object", @@ -8710,7 +9218,7 @@ }, "vcs": { "type": "string", - "const": "git" + "enum": ["git"] }, "name": { "type": "string" @@ -8727,37 +9235,37 @@ "color": { "type": "string" } - } + }, + "additionalProperties": false }, "commands": { "type": "object", "properties": { "start": { - "description": "Startup script to run when creating a new workspace (worktree)", - "type": "string" + "type": "string", + "description": "Startup script to run when creating a new workspace (worktree)" } - } + }, + "additionalProperties": false }, "time": { "type": "object", "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "updated": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "initialized": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created", "updated"] + "required": ["created", "updated"], + "additionalProperties": false }, "sandboxes": { "type": "array", @@ -8766,364 +9274,73 @@ } } }, - "required": ["id", "worktree", "time", "sandboxes"] + "required": ["id", "worktree", "time", "sandboxes"], + "additionalProperties": false }, - "Event.project.updated": { + "Pty": { "type": "object", "properties": { "id": { "type": "string" }, - "type": { + "title": { + "type": "string" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "status": { "type": "string", - "const": "project.updated" + "enum": ["running", "exited"] }, - "properties": { - "$ref": "#/components/schemas/Project" + "pid": { + "type": "integer", + "exclusiveMinimum": 0 } }, - "required": ["id", "type", "properties"] + "required": ["id", "title", "command", "args", "cwd", "status", "pid"], + "additionalProperties": false }, - "Event.vcs.branch.updated": { + "OutputFormatText": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "const": "vcs.branch.updated" - }, - "properties": { - "type": "object", - "properties": { - "branch": { - "type": "string" - } - } + "enum": ["text"] } }, - "required": ["id", "type", "properties"] + "required": ["type"], + "additionalProperties": false + }, + "JSONSchema": { + "type": "object" }, - "Event.workspace.ready": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "workspace.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - }, - "required": ["name"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.workspace.failed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "workspace.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.workspace.restore": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "workspace.restore" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "total": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "step": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.workspace.status": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "workspace.status" - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - } - }, - "required": ["workspaceID", "status"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.worktree.ready": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "worktree.ready" - }, - "properties": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "branch": { - "type": "string" - } - }, - "required": ["name", "branch"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.worktree.failed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "worktree.failed" - }, - "properties": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] - } - }, - "required": ["id", "type", "properties"] - }, - "Pty": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - }, - "title": { - "type": "string" - }, - "command": { - "type": "string" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "cwd": { - "type": "string" - }, - "status": { - "type": "string", - "enum": ["running", "exited"] - }, - "pid": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["id", "title", "command", "args", "cwd", "status", "pid"] - }, - "Event.pty.created": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "pty.created" - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.pty.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "pty.updated" - }, - "properties": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.pty.exited": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "pty.exited" - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - }, - "exitCode": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["id", "exitCode"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.pty.deleted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "pty.deleted" - }, - "properties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^pty.*" - } - }, - "required": ["id"] - } - }, - "required": ["id", "type", "properties"] - }, - "OutputFormatText": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - } - }, - "required": ["type"] - }, - "JSONSchema": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "OutputFormatJsonSchema": { + "OutputFormatJsonSchema": { "type": "object", "properties": { "type": { "type": "string", - "const": "json_schema" + "enum": ["json_schema"] }, "schema": { "$ref": "#/components/schemas/JSONSchema" }, "retryCount": { - "default": 2, "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["type", "schema"] + "required": ["type", "schema"], + "additionalProperties": false }, "OutputFormat": { "anyOf": [ @@ -9139,27 +9356,25 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "role": { "type": "string", - "const": "user" + "enum": ["user"] }, "time": { "type": "object", "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false }, "format": { "$ref": "#/components/schemas/OutputFormat" @@ -9180,7 +9395,8 @@ } } }, - "required": ["diffs"] + "required": ["diffs"], + "additionalProperties": false }, "agent": { "type": "string" @@ -9198,53 +9414,49 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false }, "system": { "type": "string" }, "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { "type": "boolean" } } }, - "required": ["id", "sessionID", "role", "time", "agent", "model"] + "required": ["id", "sessionID", "role", "time", "agent", "model"], + "additionalProperties": false }, "AssistantMessage": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "role": { "type": "string", - "const": "assistant" + "enum": ["assistant"] }, "time": { "type": "object", "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "completed": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false }, "error": { "anyOf": [ @@ -9272,8 +9484,7 @@ ] }, "parentID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "modelID": { "type": "string" @@ -9297,7 +9508,8 @@ "type": "string" } }, - "required": ["cwd", "root"] + "required": ["cwd", "root"], + "additionalProperties": false }, "summary": { "type": "boolean" @@ -9310,42 +9522,38 @@ "properties": { "total": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "input": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "output": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "reasoning": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "cache": { "type": "object", "properties": { "read": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "write": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "reasoning", "cache"] + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false }, "structured": {}, "variant": { @@ -9368,7 +9576,8 @@ "path", "cost", "tokens" - ] + ], + "additionalProperties": false }, "Message": { "anyOf": [ @@ -9380,77 +9589,21 @@ } ] }, - "Event.message.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "message.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Message" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.message.removed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "message.removed" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - } - }, - "required": ["sessionID", "messageID"] - } - }, - "required": ["id", "type", "properties"] - }, "TextPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "text" + "enum": ["text"] }, "text": { "type": "string" @@ -9466,45 +9619,38 @@ "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "sessionID", "messageID", "type", "text"] + "required": ["id", "sessionID", "messageID", "type", "text"], + "additionalProperties": false }, "SubtaskPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "subtask" + "enum": ["subtask"] }, "prompt": { "type": "string" @@ -9525,61 +9671,56 @@ "type": "string" } }, - "required": ["providerID", "modelID"] + "required": ["providerID", "modelID"], + "additionalProperties": false }, "command": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"] + "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"], + "additionalProperties": false }, "ReasoningPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "reasoning" + "enum": ["reasoning"] }, "text": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "text", "time"] + "required": ["id", "sessionID", "messageID", "type", "text", "time"], + "additionalProperties": false }, "FilePartSourceText": { "type": "object", @@ -9589,16 +9730,15 @@ }, "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["value", "start", "end"] + "required": ["value", "start", "end"], + "additionalProperties": false }, "FileSource": { "type": "object", @@ -9608,13 +9748,14 @@ }, "type": { "type": "string", - "const": "file" + "enum": ["file"] }, "path": { "type": "string" } }, - "required": ["text", "type", "path"] + "required": ["text", "type", "path"], + "additionalProperties": false }, "Range": { "type": "object", @@ -9624,35 +9765,34 @@ "properties": { "line": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "character": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["line", "character"] + "required": ["line", "character"], + "additionalProperties": false }, "end": { "type": "object", "properties": { "line": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "character": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["line", "character"] + "required": ["line", "character"], + "additionalProperties": false } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false }, "SymbolSource": { "type": "object", @@ -9662,7 +9802,7 @@ }, "type": { "type": "string", - "const": "symbol" + "enum": ["symbol"] }, "path": { "type": "string" @@ -9675,11 +9815,11 @@ }, "kind": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["text", "type", "path", "range", "name", "kind"] + "required": ["text", "type", "path", "range", "name", "kind"], + "additionalProperties": false }, "ResourceSource": { "type": "object", @@ -9689,7 +9829,7 @@ }, "type": { "type": "string", - "const": "resource" + "enum": ["resource"] }, "clientName": { "type": "string" @@ -9698,7 +9838,8 @@ "type": "string" } }, - "required": ["text", "type", "clientName", "uri"] + "required": ["text", "type", "clientName", "uri"], + "additionalProperties": false }, "FilePartSource": { "anyOf": [ @@ -9717,20 +9858,17 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "file" + "enum": ["file"] }, "mime": { "type": "string" @@ -9745,79 +9883,66 @@ "$ref": "#/components/schemas/FilePartSource" } }, - "required": ["id", "sessionID", "messageID", "type", "mime", "url"] + "required": ["id", "sessionID", "messageID", "type", "mime", "url"], + "additionalProperties": false }, "ToolStatePending": { "type": "object", "properties": { "status": { "type": "string", - "const": "pending" + "enum": ["pending"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "raw": { "type": "string" } }, - "required": ["status", "input", "raw"] + "required": ["status", "input", "raw"], + "additionalProperties": false }, "ToolStateRunning": { "type": "object", "properties": { "status": { "type": "string", - "const": "running" + "enum": ["running"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "title": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start"] + "required": ["start"], + "additionalProperties": false } }, - "required": ["status", "input", "time"] + "required": ["status", "input", "time"], + "additionalProperties": false }, "ToolStateCompleted": { "type": "object", "properties": { "status": { "type": "string", - "const": "completed" + "enum": ["completed"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "output": { "type": "string" @@ -9826,32 +9951,26 @@ "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "compacted": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false }, "attachments": { "type": "array", @@ -9860,50 +9979,43 @@ } } }, - "required": ["status", "input", "output", "title", "metadata", "time"] + "required": ["status", "input", "output", "title", "metadata", "time"], + "additionalProperties": false }, "ToolStateError": { "type": "object", "properties": { "status": { "type": "string", - "const": "error" + "enum": ["error"] }, "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "error": { "type": "string" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" }, "time": { "type": "object", "properties": { "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["start", "end"] + "required": ["start", "end"], + "additionalProperties": false } }, - "required": ["status", "input", "error", "time"] + "required": ["status", "input", "error", "time"], + "additionalProperties": false }, "ToolState": { "anyOf": [ @@ -9925,20 +10037,17 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "tool" + "enum": ["tool"] }, "callID": { "type": "string" @@ -9950,58 +10059,50 @@ "$ref": "#/components/schemas/ToolState" }, "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "type": "object" } }, - "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"] + "required": ["id", "sessionID", "messageID", "type", "callID", "tool", "state"], + "additionalProperties": false }, "StepStartPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "step-start" + "enum": ["step-start"] }, "snapshot": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type"] + "required": ["id", "sessionID", "messageID", "type"], + "additionalProperties": false }, "StepFinishPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "step-finish" + "enum": ["step-finish"] }, "reason": { "type": "string" @@ -10017,89 +10118,81 @@ "properties": { "total": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "input": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "output": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "reasoning": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "cache": { "type": "object", "properties": { "read": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "write": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "reasoning", "cache"] + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"] + "required": ["id", "sessionID", "messageID", "type", "reason", "cost", "tokens"], + "additionalProperties": false }, "SnapshotPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "snapshot" + "enum": ["snapshot"] }, "snapshot": { "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "snapshot"] + "required": ["id", "sessionID", "messageID", "type", "snapshot"], + "additionalProperties": false }, "PatchPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "patch" + "enum": ["patch"] }, "hash": { "type": "string" @@ -10111,26 +10204,24 @@ } } }, - "required": ["id", "sessionID", "messageID", "type", "hash", "files"] + "required": ["id", "sessionID", "messageID", "type", "hash", "files"], + "additionalProperties": false }, "AgentPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "agent" + "enum": ["agent"] }, "name": { "type": "string" @@ -10143,43 +10234,39 @@ }, "start": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "end": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["value", "start", "end"] + "required": ["value", "start", "end"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "name"] + "required": ["id", "sessionID", "messageID", "type", "name"], + "additionalProperties": false }, "RetryPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "retry" + "enum": ["retry"] }, "attempt": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "error": { "$ref": "#/components/schemas/APIError" @@ -10189,33 +10276,31 @@ "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } }, - "required": ["created"] + "required": ["created"], + "additionalProperties": false } }, - "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"] + "required": ["id", "sessionID", "messageID", "type", "attempt", "error", "time"], + "additionalProperties": false }, "CompactionPart": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "sessionID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "type": { "type": "string", - "const": "compaction" + "enum": ["compaction"] }, "auto": { "type": "boolean" @@ -10224,11 +10309,11 @@ "type": "boolean" }, "tail_start_id": { - "type": "string", - "pattern": "^msg.*" + "type": "string" } }, - "required": ["id", "sessionID", "messageID", "type", "auto"] + "required": ["id", "sessionID", "messageID", "type", "auto"], + "additionalProperties": false }, "Part": { "anyOf": [ @@ -10270,68 +10355,6 @@ } ] }, - "Event.message.part.updated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "message.part.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "part": { - "$ref": "#/components/schemas/Part" - }, - "time": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["sessionID", "part", "time"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.message.part.removed": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "message.part.removed" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - } - }, - "required": ["sessionID", "messageID", "partID"] - } - }, - "required": ["id", "type", "properties"] - }, "PermissionAction": { "type": "string", "enum": ["allow", "deny", "ask"] @@ -10349,7 +10372,8 @@ "$ref": "#/components/schemas/PermissionAction" } }, - "required": ["permission", "pattern", "action"] + "required": ["permission", "pattern", "action"], + "additionalProperties": false }, "PermissionRuleset": { "type": "array", @@ -10361,8 +10385,7 @@ "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "slug": { "type": "string" @@ -10371,8 +10394,7 @@ "type": "string" }, "workspaceID": { - "type": "string", - "pattern": "^wrk.*" + "type": "string" }, "directory": { "type": "string" @@ -10381,26 +10403,22 @@ "type": "string" }, "parentID": { - "type": "string", - "pattern": "^ses.*" + "type": "string" }, "summary": { "type": "object", "properties": { "additions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "deletions": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "files": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "diffs": { "type": "array", @@ -10409,7 +10427,8 @@ } } }, - "required": ["additions", "deletions", "files"] + "required": ["additions", "deletions", "files"], + "additionalProperties": false }, "share": { "type": "object", @@ -10418,7 +10437,8 @@ "type": "string" } }, - "required": ["url"] + "required": ["url"], + "additionalProperties": false }, "title": { "type": "string" @@ -10439,7 +10459,8 @@ "type": "string" } }, - "required": ["id", "providerID"] + "required": ["id", "providerID"], + "additionalProperties": false }, "version": { "type": "string" @@ -10449,24 +10470,22 @@ "properties": { "created": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "updated": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "compacting": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, "archived": { "type": "number" } }, - "required": ["created", "updated"] + "required": ["created", "updated"], + "additionalProperties": false }, "permission": { "$ref": "#/components/schemas/PermissionRuleset" @@ -10475,12 +10494,10 @@ "type": "object", "properties": { "messageID": { - "type": "string", - "pattern": "^msg.*" + "type": "string" }, "partID": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "snapshot": { "type": "string" @@ -10489,5914 +10506,7423 @@ "type": "string" } }, - "required": ["messageID"] + "required": ["messageID"], + "additionalProperties": false } }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time"] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time"], + "additionalProperties": false }, - "Event.session.created": { + "Prompt": { "type": "object", "properties": { - "id": { + "text": { "type": "string" }, - "type": { - "type": "string", - "const": "session.created" + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } } }, - "required": ["id", "type", "properties"] + "required": ["text"], + "additionalProperties": false }, - "Event.session.updated": { + "GlobalEvent": { "type": "object", "properties": { - "id": { + "directory": { "type": "string" }, - "type": { - "type": "string", - "const": "session.updated" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.deleted": { - "type": "object", - "properties": { - "id": { + "project": { "type": "string" }, - "type": { - "type": "string", - "const": "session.deleted" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.agent.switched": { - "type": "object", - "properties": { - "id": { + "workspace": { "type": "string" }, - "type": { - "type": "string", - "const": "session.next.agent.switched" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + "payload": { + "anyOf": [ + { + "$ref": "#/components/schemas/EventServerInstanceDisposed" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventFileEdited" }, - "agent": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "agent"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.model.switched": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.model.switched" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventFileWatcherUpdated" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventLspClientDiagnostics" }, - "id": { - "type": "string" + { + "$ref": "#/components/schemas/EventLspUpdated" }, - "providerID": { - "type": "string" + { + "$ref": "#/components/schemas/EventMessagePartDelta" }, - "variant": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "id", "providerID"] - } - }, - "required": ["id", "type", "properties"] - }, - "Prompt.Source": { - "type": "object", - "properties": { - "start": { - "type": "number" - }, - "end": { - "type": "number" - }, - "text": { - "type": "string" - } - }, - "required": ["start", "end", "text"] - }, - "Prompt.FileAttachment": { - "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "mime": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" - } - }, - "required": ["uri", "mime"] - }, - "Prompt.AgentAttachment": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "source": { - "$ref": "#/components/schemas/Prompt.Source" - } - }, - "required": ["name"] - }, - "Prompt": { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prompt.FileAttachment" - } - }, - "agents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Prompt.AgentAttachment" - } - } - }, - "required": ["text"] - }, - "Event.session.next.prompted": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.prompted" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventPermissionAsked" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventPermissionReplied" }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - } - }, - "required": ["timestamp", "sessionID", "prompt"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.synthetic": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.synthetic" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventSessionDiff" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventSessionError" }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.shell.started": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.shell.started" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventInstallationUpdated" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventInstallationUpdate-available" }, - "callID": { - "type": "string" + { + "$ref": "#/components/schemas/EventQuestionAsked" }, - "command": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "command"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.shell.ended": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.shell.ended" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventQuestionReplied" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventQuestionRejected" }, - "callID": { - "type": "string" + { + "$ref": "#/components/schemas/EventTodoUpdated" }, - "output": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "output"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.step.started": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.step.started" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventSessionStatus" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventSessionIdle" }, - "agent": { - "type": "string" + { + "$ref": "#/components/schemas/EventSessionCompacted" }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"] + { + "$ref": "#/components/schemas/Event.tui.prompt.append" }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "agent", "model"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.step.ended": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.step.ended" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/Event.tui.command.execute" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/Event.tui.toast.show" }, - "finish": { - "type": "string" + { + "$ref": "#/components/schemas/Event.tui.session.select" }, - "cost": { - "type": "number" + { + "$ref": "#/components/schemas/EventMcpToolsChanged" }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "output": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "reasoning": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "write": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["read", "write"] - } - }, - "required": ["input", "output", "reasoning", "cache"] + { + "$ref": "#/components/schemas/EventMcpBrowserOpenFailed" }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "finish", "cost", "tokens"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.text.started": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.text.started" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventCommandExecuted" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["timestamp", "sessionID"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.text.delta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.text.delta" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventProjectUpdated" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventVcsBranchUpdated" }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "delta"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.text.ended": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.text.ended" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventWorkspaceReady" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventWorkspaceFailed" }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.reasoning.started": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.reasoning.started" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventWorkspaceRestore" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventWorkspaceStatus" }, - "reasoningID": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.reasoning.delta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.reasoning.delta" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventWorktreeReady" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventWorktreeFailed" }, - "reasoningID": { - "type": "string" + { + "$ref": "#/components/schemas/EventPtyCreated" }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.reasoning.ended": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.reasoning.ended" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventPtyUpdated" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventPtyExited" }, - "reasoningID": { - "type": "string" + { + "$ref": "#/components/schemas/EventPtyDeleted" }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "text"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.input.started": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.input.started" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventMessageUpdated" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventMessageRemoved" }, - "callID": { - "type": "string" + { + "$ref": "#/components/schemas/EventMessagePartUpdated" }, - "name": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "name"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.input.delta": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.input.delta" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventMessagePartRemoved" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventSessionCreated" }, - "callID": { - "type": "string" + { + "$ref": "#/components/schemas/EventSessionUpdated" }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "delta"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.input.ended": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.input.ended" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventSessionDeleted" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventSessionNextAgentSwitched" }, - "callID": { - "type": "string" + { + "$ref": "#/components/schemas/EventSessionNextModelSwitched" }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "text"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.called": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.called" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + { + "$ref": "#/components/schemas/EventSessionNextPrompted" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + { + "$ref": "#/components/schemas/EventSessionNextSynthetic" }, - "callID": { - "type": "string" + { + "$ref": "#/components/schemas/EventSessionNextShellStarted" }, - "tool": { - "type": "string" + { + "$ref": "#/components/schemas/EventSessionNextShellEnded" }, - "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + { + "$ref": "#/components/schemas/EventSessionNextStepStarted" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["executed"] + { + "$ref": "#/components/schemas/EventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/EventSessionNextToolError" + }, + { + "$ref": "#/components/schemas/EventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/EventSessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/EventServerConnected" + }, + { + "$ref": "#/components/schemas/EventGlobalDisposed" + }, + { + "$ref": "#/components/schemas/SyncEventMessageUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventMessageRemoved" + }, + { + "$ref": "#/components/schemas/SyncEventMessagePartUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventMessagePartRemoved" + }, + { + "$ref": "#/components/schemas/SyncEventSessionCreated" + }, + { + "$ref": "#/components/schemas/SyncEventSessionUpdated" + }, + { + "$ref": "#/components/schemas/SyncEventSessionDeleted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextPrompted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextToolError" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextRetried" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/SyncEventSessionNextCompactionEnded" } - }, - "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"] + ] } }, - "required": ["id", "type", "properties"] + "required": ["directory", "payload"], + "additionalProperties": false }, - "Tool.TextContent": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - }, - "text": { - "type": "string" - } - }, - "required": ["type", "text"] + "LogLevel": { + "type": "string", + "enum": ["DEBUG", "INFO", "WARN", "ERROR"], + "description": "Log level" }, - "Tool.FileContent": { + "ServerConfig": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "file" + "port": { + "type": "integer", + "exclusiveMinimum": 0 }, - "uri": { + "hostname": { "type": "string" }, - "mime": { - "type": "string" + "mdns": { + "type": "boolean" }, - "name": { + "mdnsDomain": { "type": "string" + }, + "cors": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["type", "uri", "mime"] + "additionalProperties": false, + "description": "Server configuration for opencode serve and web commands" }, - "Event.session.next.tool.progress": { - "type": "object", - "properties": { - "id": { - "type": "string" + "PermissionActionConfig": { + "type": "string", + "enum": ["ask", "allow", "deny"] + }, + "PermissionObjectConfig": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/PermissionActionConfig" + } + }, + "PermissionRuleConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionActionConfig" }, - "type": { - "type": "string", - "const": "session.next.tool.progress" + { + "$ref": "#/components/schemas/PermissionObjectConfig" + } + ] + }, + "PermissionConfig": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionActionConfig" }, - "properties": { + { "type": "object", "properties": { - "timestamp": { - "type": "number" + "read": { + "$ref": "#/components/schemas/PermissionRuleConfig" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "edit": { + "$ref": "#/components/schemas/PermissionRuleConfig" }, - "callID": { - "type": "string" + "glob": { + "$ref": "#/components/schemas/PermissionRuleConfig" }, - "structured": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "grep": { + "$ref": "#/components/schemas/PermissionRuleConfig" }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/Tool.TextContent" - }, - { - "$ref": "#/components/schemas/Tool.FileContent" - } - ] - } - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.success": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.success" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + "list": { + "$ref": "#/components/schemas/PermissionRuleConfig" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "bash": { + "$ref": "#/components/schemas/PermissionRuleConfig" }, - "callID": { - "type": "string" + "task": { + "$ref": "#/components/schemas/PermissionRuleConfig" }, - "structured": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "external_directory": { + "$ref": "#/components/schemas/PermissionRuleConfig" }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/Tool.TextContent" - }, - { - "$ref": "#/components/schemas/Tool.FileContent" - } - ] - } + "todowrite": { + "$ref": "#/components/schemas/PermissionActionConfig" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["executed"] - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.session.next.tool.error": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "const": "session.next.tool.error" - }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" + "question": { + "$ref": "#/components/schemas/PermissionActionConfig" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "webfetch": { + "$ref": "#/components/schemas/PermissionActionConfig" }, - "callID": { - "type": "string" + "websearch": { + "$ref": "#/components/schemas/PermissionActionConfig" }, - "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"] + "lsp": { + "$ref": "#/components/schemas/PermissionRuleConfig" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["executed"] + "doom_loop": { + "$ref": "#/components/schemas/PermissionActionConfig" + }, + "skill": { + "$ref": "#/components/schemas/PermissionRuleConfig" } }, - "required": ["timestamp", "sessionID", "callID", "error", "provider"] + "additionalProperties": { + "$ref": "#/components/schemas/PermissionRuleConfig" + } } - }, - "required": ["id", "type", "properties"] + ] }, - "session.next.retry_error": { + "AgentConfig": { "type": "object", "properties": { - "message": { + "model": { "type": "string" }, - "statusCode": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "variant": { + "type": "string" }, - "isRetryable": { - "type": "boolean" + "temperature": { + "type": "number" }, - "responseHeaders": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } + "top_p": { + "type": "number" }, - "responseBody": { + "prompt": { "type": "string" }, - "metadata": { + "tools": { "type": "object", - "propertyNames": { - "type": "string" - }, "additionalProperties": { - "type": "string" + "type": "boolean" } - } - }, - "required": ["message", "isRetryable"] - }, - "Event.session.next.retried": { - "type": "object", - "properties": { - "id": { + }, + "disable": { + "type": "boolean" + }, + "description": { "type": "string" }, - "type": { + "mode": { "type": "string", - "const": "session.next.retried" + "enum": ["subagent", "primary", "all"] }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { + "hidden": { + "type": "boolean" + }, + "options": { + "type": "object" + }, + "color": { + "anyOf": [ + { "type": "string", - "pattern": "^ses.*" - }, - "attempt": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "pattern": "^#[0-9a-fA-F]{6}$" }, - "error": { - "$ref": "#/components/schemas/session.next.retry_error" + { + "type": "string", + "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] } - }, - "required": ["timestamp", "sessionID", "attempt", "error"] + ], + "description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)" + }, + "steps": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "maxSteps": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "permission": { + "$ref": "#/components/schemas/PermissionConfig" } }, - "required": ["id", "type", "properties"] + "additionalProperties": {} }, - "Event.session.next.compaction.started": { + "ProviderConfig": { "type": "object", "properties": { + "api": { + "type": "string" + }, + "name": { + "type": "string" + }, + "env": { + "type": "array", + "items": { + "type": "string" + } + }, "id": { "type": "string" }, - "type": { - "type": "string", - "const": "session.next.compaction.started" + "npm": { + "type": "string" }, - "properties": { + "whitelist": { + "type": "array", + "items": { + "type": "string" + } + }, + "blacklist": { + "type": "array", + "items": { + "type": "string" + } + }, + "options": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "apiKey": { + "type": "string" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "baseURL": { + "type": "string" }, - "reason": { - "type": "string", - "enum": ["auto", "manual"] + "enterpriseUrl": { + "type": "string" + }, + "setCacheKey": { + "type": "boolean" + }, + "timeout": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0 + }, + { + "type": "boolean", + "enum": [false] + } + ], + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout." + }, + "chunkTimeout": { + "type": "integer", + "exclusiveMinimum": 0 } }, - "required": ["timestamp", "sessionID", "reason"] + "additionalProperties": {} + }, + "models": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "family": { + "type": "string" + }, + "release_date": { + "type": "string" + }, + "attachment": { + "type": "boolean" + }, + "reasoning": { + "type": "boolean" + }, + "temperature": { + "type": "boolean" + }, + "tool_call": { + "type": "boolean" + }, + "interleaved": { + "anyOf": [ + { + "type": "boolean", + "enum": [true] + }, + { + "type": "object", + "properties": { + "field": { + "type": "string", + "enum": ["reasoning_content", "reasoning_details"] + } + }, + "required": ["field"], + "additionalProperties": false + } + ] + }, + "cost": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache_read": { + "type": "number" + }, + "cache_write": { + "type": "number" + }, + "context_over_200k": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache_read": { + "type": "number" + }, + "cache_write": { + "type": "number" + } + }, + "required": ["input", "output"], + "additionalProperties": false + } + }, + "required": ["input", "output"], + "additionalProperties": false + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "number" + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + } + }, + "required": ["context", "output"], + "additionalProperties": false + }, + "modalities": { + "type": "object", + "properties": { + "input": { + "type": "array", + "items": { + "type": "string", + "enum": ["text", "audio", "image", "video", "pdf"] + } + }, + "output": { + "type": "array", + "items": { + "type": "string", + "enum": ["text", "audio", "image", "video", "pdf"] + } + } + }, + "required": ["input", "output"], + "additionalProperties": false + }, + "experimental": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated"] + }, + "provider": { + "type": "object", + "properties": { + "npm": { + "type": "string" + }, + "api": { + "type": "string" + } + }, + "additionalProperties": false + }, + "options": { + "type": "object" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "variants": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + } + }, + "additionalProperties": {} + }, + "description": "Variant-specific configuration" + } + }, + "additionalProperties": false + } } }, - "required": ["id", "type", "properties"] + "additionalProperties": false }, - "Event.session.next.compaction.delta": { + "McpLocalConfig": { "type": "object", "properties": { - "id": { - "type": "string" - }, "type": { "type": "string", - "const": "session.next.compaction.delta" + "enum": ["local"], + "description": "Type of MCP server connection" }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - } + "command": { + "type": "array", + "items": { + "type": "string" }, - "required": ["timestamp", "sessionID", "text"] + "description": "Command and arguments to run the MCP server" + }, + "environment": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "timeout": { + "type": "integer", + "exclusiveMinimum": 0 } }, - "required": ["id", "type", "properties"] + "required": ["type", "command"], + "additionalProperties": false }, - "Event.session.next.compaction.ended": { + "McpOAuthConfig": { "type": "object", "properties": { - "id": { + "clientId": { "type": "string" }, - "type": { - "type": "string", - "const": "session.next.compaction.ended" + "clientSecret": { + "type": "string" }, - "properties": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - }, - "include": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["id", "type", "properties"] - }, - "Event.server.connected": { - "type": "object", - "properties": { - "id": { + "scope": { "type": "string" }, - "type": { - "type": "string", - "const": "server.connected" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"] - }, - "Event.global.disposed": { - "type": "object", - "properties": { - "id": { + "redirectUri": { "type": "string" - }, - "type": { - "type": "string", - "const": "global.disposed" - }, - "properties": { - "type": "object", - "properties": {} } }, - "required": ["id", "type", "properties"] + "additionalProperties": false }, - "SyncEvent.message.updated": { + "McpRemoteConfig": { "type": "object", "properties": { "type": { "type": "string", - "const": "sync" + "enum": ["remote"], + "description": "Type of MCP server connection" }, - "name": { + "url": { "type": "string", - "const": "message.updated.1" - }, - "id": { - "type": "string" + "description": "URL of the remote MCP server" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "enabled": { + "type": "boolean" }, - "data": { + "headers": { "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "additionalProperties": { + "type": "string" + } + }, + "oauth": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpOAuthConfig" }, - "info": { - "$ref": "#/components/schemas/Message" + { + "type": "boolean", + "enum": [false] } - }, - "required": ["sessionID", "info"] + ], + "description": "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection." + }, + "timeout": { + "type": "integer", + "exclusiveMinimum": 0 } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["type", "url"], + "additionalProperties": false + }, + "LayoutConfig": { + "type": "string", + "enum": ["auto", "stretch"], + "description": "@deprecated Always uses stretch layout." }, - "SyncEvent.message.removed": { + "Config": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.removed.1" + "$schema": { + "type": "string" }, - "id": { + "shell": { "type": "string" }, - "seq": { - "type": "number" + "logLevel": { + "$ref": "#/components/schemas/LogLevel" }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "server": { + "$ref": "#/components/schemas/ServerConfig" }, - "data": { + "command": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "template": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "string" + }, + "subtask": { + "type": "boolean" + } + }, + "required": ["template"], + "additionalProperties": false + } + }, + "skills": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "paths": { + "type": "array", + "items": { + "type": "string" + } }, - "messageID": { - "type": "string", - "pattern": "^msg.*" + "urls": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["sessionID", "messageID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.message.part.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" + "additionalProperties": false }, - "name": { - "type": "string", - "const": "message.part.updated.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { + "watcher": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "part": { - "$ref": "#/components/schemas/Part" - }, - "time": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "ignore": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["sessionID", "part", "time"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.message.part.removed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "message.part.removed.1" + "additionalProperties": false }, - "id": { - "type": "string" + "snapshot": { + "type": "boolean" }, - "seq": { - "type": "number" + "plugin": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "prefixItems": [ + { + "type": "string" + }, + { + "type": "object" + } + ], + "maxItems": 2, + "minItems": 2 + } + ] + } }, - "aggregateID": { + "share": { "type": "string", - "const": "sessionID" + "enum": ["manual", "auto", "disabled"] }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" + "autoshare": { + "type": "boolean" + }, + "autoupdate": { + "anyOf": [ + { + "type": "boolean" }, - "partID": { + { "type": "string", - "pattern": "^prt.*" + "enum": ["notify"] } - }, - "required": ["sessionID", "messageID", "partID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.created": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" + ], + "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications" }, - "name": { - "type": "string", - "const": "session.created.1" + "disabled_providers": { + "type": "array", + "items": { + "type": "string" + } }, - "id": { + "enabled_providers": { + "type": "array", + "items": { + "type": "string" + } + }, + "model": { "type": "string" }, - "seq": { - "type": "number" + "small_model": { + "type": "string" }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "default_agent": { + "type": "string" }, - "data": { + "username": { + "type": "string" + }, + "mode": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "build": { + "$ref": "#/components/schemas/AgentConfig" }, - "info": { - "$ref": "#/components/schemas/Session" + "plan": { + "$ref": "#/components/schemas/AgentConfig" } }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.updated.1" - }, - "id": { - "type": "string" + "additionalProperties": { + "$ref": "#/components/schemas/AgentConfig" + } }, - "seq": { - "type": "number" + "agent": { + "type": "object", + "properties": { + "plan": { + "$ref": "#/components/schemas/AgentConfig" + }, + "build": { + "$ref": "#/components/schemas/AgentConfig" + }, + "general": { + "$ref": "#/components/schemas/AgentConfig" + }, + "explore": { + "$ref": "#/components/schemas/AgentConfig" + }, + "title": { + "$ref": "#/components/schemas/AgentConfig" + }, + "summary": { + "$ref": "#/components/schemas/AgentConfig" + }, + "compaction": { + "$ref": "#/components/schemas/AgentConfig" + } + }, + "additionalProperties": { + "$ref": "#/components/schemas/AgentConfig" + } }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "provider": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ProviderConfig" + } }, - "data": { + "mcp": { "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/components/schemas/McpLocalConfig" + }, + { + "$ref": "#/components/schemas/McpRemoteConfig" + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"], + "additionalProperties": false + } + ] + } + }, + "formatter": { + "anyOf": [ + { + "type": "boolean" }, - "info": { + { "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses.*" - }, - { - "type": "null" - } - ] - }, - "slug": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "projectID": { - "anyOf": [ - { + "additionalProperties": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean" + }, + "command": { + "type": "array", + "items": { "type": "string" - }, - { - "type": "null" - } - ] - }, - "workspaceID": { - "anyOf": [ - { - "type": "string", - "pattern": "^wrk.*" - }, - { - "type": "null" } - ] - }, - "directory": { - "anyOf": [ - { + }, + "environment": { + "type": "object", + "additionalProperties": { "type": "string" - }, - { - "type": "null" } - ] - }, - "path": { - "anyOf": [ - { + }, + "extensions": { + "type": "array", + "items": { "type": "string" - }, - { - "type": "null" } - ] + } }, - "parentID": { - "anyOf": [ - { - "type": "string", - "pattern": "^ses.*" + "additionalProperties": false + } + } + ], + "description": "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides." + }, + "lsp": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "enum": [true] + } }, - { - "type": "null" - } - ] - }, - "summary": { - "anyOf": [ - { - "type": "object", - "properties": { - "additions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "deletions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "files": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "diffs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } + "required": ["disabled"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "command": { + "type": "array", + "items": { + "type": "string" } }, - "required": ["additions", "deletions", "files"] - }, - { - "type": "null" - } - ] - }, - "share": { - "type": "object", - "properties": { - "url": { - "anyOf": [ - { + "extensions": { + "type": "array", + "items": { "type": "string" - }, - { - "type": "null" } - ] - } + }, + "disabled": { + "type": "boolean" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "initialization": { + "type": "object" + } + }, + "required": ["command"], + "additionalProperties": false } - }, - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "agent": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "model": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"] - }, - { - "type": "null" - } - ] - }, - "version": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "time": { - "type": "object", - "properties": { - "created": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "updated": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "compacting": { - "anyOf": [ - { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "null" - } - ] - }, - "archived": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ] - } - } - }, - "permission": { - "anyOf": [ - { - "$ref": "#/components/schemas/PermissionRuleset" - }, - { - "type": "null" - } - ] - }, - "revert": { - "anyOf": [ - { - "type": "object", - "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" - }, - "snapshot": { - "type": "string" - }, - "diff": { - "type": "string" - } - }, - "required": ["messageID"] - }, - { - "type": "null" - } - ] - } + ] } } - }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.deleted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" + ], + "description": "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides." }, - "name": { - "type": "string", - "const": "session.deleted.1" + "instructions": { + "type": "array", + "items": { + "type": "string" + } }, - "id": { - "type": "string" + "layout": { + "$ref": "#/components/schemas/LayoutConfig" }, - "seq": { - "type": "number" + "permission": { + "$ref": "#/components/schemas/PermissionConfig" }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "tools": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } }, - "data": { + "enterprise": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "info": { - "$ref": "#/components/schemas/Session" + "url": { + "type": "string" } }, - "required": ["sessionID", "info"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.agent.switched": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.agent.switched.1" + "additionalProperties": false }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" + "tool_output": { + "type": "object", + "properties": { + "max_lines": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "max_bytes": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "additionalProperties": false }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "compaction": { + "type": "object", + "properties": { + "auto": { + "type": "boolean" + }, + "prune": { + "type": "boolean" + }, + "tail_turns": { + "type": "integer", + "minimum": 0 + }, + "preserve_recent_tokens": { + "type": "integer", + "minimum": 0 + }, + "reserved": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false }, - "data": { + "experimental": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "disable_paste_summary": { + "type": "boolean" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "batch_tool": { + "type": "boolean" }, - "agent": { - "type": "string" + "openTelemetry": { + "type": "boolean" + }, + "primary_tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "continue_loop_on_deny": { + "type": "boolean" + }, + "mcp_timeout": { + "type": "integer", + "exclusiveMinimum": 0 } }, - "required": ["timestamp", "sessionID", "agent"] + "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "additionalProperties": false }, - "SyncEvent.session.next.model.switched": { + "Model": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.model.switched.1" - }, "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "providerID": { + "type": "string" }, - "data": { + "api": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, "id": { "type": "string" }, - "providerID": { + "url": { "type": "string" }, - "variant": { + "npm": { "type": "string" } }, - "required": ["timestamp", "sessionID", "id", "providerID"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.prompted": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" + "required": ["id", "url", "npm"], + "additionalProperties": false }, "name": { - "type": "string", - "const": "session.next.prompted.1" - }, - "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "family": { + "type": "string" }, - "data": { + "capabilities": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "temperature": { + "type": "boolean" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "reasoning": { + "type": "boolean" }, - "prompt": { - "$ref": "#/components/schemas/Prompt" + "attachment": { + "type": "boolean" + }, + "toolcall": { + "type": "boolean" + }, + "input": { + "type": "object", + "properties": { + "text": { + "type": "boolean" + }, + "audio": { + "type": "boolean" + }, + "image": { + "type": "boolean" + }, + "video": { + "type": "boolean" + }, + "pdf": { + "type": "boolean" + } + }, + "required": ["text", "audio", "image", "video", "pdf"], + "additionalProperties": false + }, + "output": { + "type": "object", + "properties": { + "text": { + "type": "boolean" + }, + "audio": { + "type": "boolean" + }, + "image": { + "type": "boolean" + }, + "video": { + "type": "boolean" + }, + "pdf": { + "type": "boolean" + } + }, + "required": ["text", "audio", "image", "video", "pdf"], + "additionalProperties": false + }, + "interleaved": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "object", + "properties": { + "field": { + "type": "string", + "enum": ["reasoning_content", "reasoning_details"] + } + }, + "required": ["field"], + "additionalProperties": false + } + ] } }, - "required": ["timestamp", "sessionID", "prompt"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.synthetic": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.synthetic.1" - }, - "id": { - "type": "string" + "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"], + "additionalProperties": false }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { + "cost": { "type": "object", "properties": { - "timestamp": { + "input": { "type": "number" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "output": { + "type": "number" }, - "text": { - "type": "string" + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + }, + "experimentalOver200K": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "text"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.shell.started": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.shell.started.1" - }, - "id": { - "type": "string" - }, - "seq": { - "type": "number" + "required": ["input", "output", "cache"], + "additionalProperties": false }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { + "limit": { "type": "object", "properties": { - "timestamp": { + "context": { "type": "number" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" + "input": { + "type": "number" }, - "command": { - "type": "string" + "output": { + "type": "number" } }, - "required": ["timestamp", "sessionID", "callID", "command"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.shell.ended": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" + "required": ["context", "output"], + "additionalProperties": false }, - "name": { + "status": { "type": "string", - "const": "session.next.shell.ended.1" + "enum": ["alpha", "beta", "deprecated", "active"] }, - "id": { - "type": "string" + "options": { + "type": "object" }, - "seq": { - "type": "number" + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "release_date": { + "type": "string" }, - "data": { + "variants": { "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "output": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "output"] + "additionalProperties": { + "type": "object" + } } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": [ + "id", + "providerID", + "api", + "name", + "capabilities", + "cost", + "limit", + "status", + "options", + "headers", + "release_date" + ], + "additionalProperties": false }, - "SyncEvent.session.next.step.started": { + "Provider": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" + "id": { + "type": "string" }, "name": { - "type": "string", - "const": "session.next.step.started.1" - }, - "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { + "source": { "type": "string", - "const": "sessionID" + "enum": ["env", "config", "custom", "api"] }, - "data": { + "env": { + "type": "array", + "items": { + "type": "string" + } + }, + "key": { + "type": "string" + }, + "options": { + "type": "object" + }, + "models": { "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" - } - }, - "required": ["id", "providerID"] - }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "agent", "model"] + "additionalProperties": { + "$ref": "#/components/schemas/Model" + } } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["id", "name", "source", "env", "options", "models"], + "additionalProperties": false }, - "SyncEvent.session.next.step.ended": { + "ConsoleState": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.step.ended.1" + "consoleManagedProviders": { + "type": "array", + "items": { + "type": "string" + } }, - "id": { + "activeOrgName": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "finish": { - "type": "string" - }, - "cost": { - "type": "number" - }, - "tokens": { - "type": "object", - "properties": { - "input": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "output": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "reasoning": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "write": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["read", "write"] - } - }, - "required": ["input", "output", "reasoning", "cache"] - }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "finish", "cost", "tokens"] + "switchableOrgCount": { + "type": "integer", + "minimum": 0 } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["consoleManagedProviders", "switchableOrgCount"], + "additionalProperties": false }, - "SyncEvent.session.next.text.started": { + "ToolListItem": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.text.started.1" - }, "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "description": { + "type": "string" }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["timestamp", "sessionID"] - } + "parameters": {} }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["id", "description", "parameters"], + "additionalProperties": false + }, + "ToolList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ToolListItem" + } }, - "SyncEvent.session.next.text.delta": { + "ToolIDs": { + "type": "array", + "items": { + "type": "string" + } + }, + "WorktreeCreateInput": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, "name": { - "type": "string", - "const": "session.next.text.delta.1" - }, - "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { + "startCommand": { "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "delta"] + "description": "Additional startup script to run after the project's start command" } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "additionalProperties": false }, - "SyncEvent.session.next.text.ended": { + "Worktree": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, "name": { - "type": "string", - "const": "session.next.text.ended.1" - }, - "id": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "branch": { + "type": "string" }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] + "directory": { + "type": "string" } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["name", "branch", "directory"], + "additionalProperties": false }, - "SyncEvent.session.next.reasoning.started": { + "WorktreeRemoveInput": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.reasoning.started.1" - }, - "id": { + "directory": { "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "reasoningID": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID"] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["directory"], + "additionalProperties": false }, - "SyncEvent.session.next.reasoning.delta": { + "WorktreeResetInput": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.reasoning.delta.1" - }, - "id": { + "directory": { "type": "string" - }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "reasoningID": { - "type": "string" - }, - "delta": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "reasoningID", "delta"] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["directory"], + "additionalProperties": false }, - "SyncEvent.session.next.reasoning.ended": { + "ProjectSummary": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" + "id": { + "type": "string" }, "name": { - "type": "string", - "const": "session.next.reasoning.ended.1" + "type": "string" }, + "worktree": { + "type": "string" + } + }, + "required": ["id", "worktree"], + "additionalProperties": false + }, + "GlobalSession": { + "type": "object", + "properties": { "id": { "type": "string" }, - "seq": { - "type": "number" + "slug": { + "type": "string" }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "projectID": { + "type": "string" }, - "data": { + "workspaceID": { + "type": "string" + }, + "directory": { + "type": "string" + }, + "path": { + "type": "string" + }, + "parentID": { + "type": "string" + }, + "summary": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "additions": { + "type": "integer", + "minimum": 0 }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "deletions": { + "type": "integer", + "minimum": 0 }, - "reasoningID": { - "type": "string" + "files": { + "type": "integer", + "minimum": 0 }, - "text": { - "type": "string" + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } } }, - "required": ["timestamp", "sessionID", "reasoningID", "text"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.tool.input.started": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" + "required": ["additions", "deletions", "files"], + "additionalProperties": false }, - "name": { - "type": "string", - "const": "session.next.tool.input.started.1" + "share": { + "type": "object", + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "additionalProperties": false }, - "id": { + "title": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "agent": { + "type": "string" }, - "data": { + "model": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "id": { + "type": "string" }, - "callID": { + "providerID": { "type": "string" }, - "name": { + "variant": { "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "name"] - } - }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] - }, - "SyncEvent.session.next.tool.input.delta": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "sync" + "required": ["id", "providerID"], + "additionalProperties": false }, - "name": { - "type": "string", - "const": "session.next.tool.input.delta.1" - }, - "id": { + "version": { "type": "string" }, - "seq": { - "type": "number" + "time": { + "type": "object", + "properties": { + "created": { + "type": "integer", + "minimum": 0 + }, + "updated": { + "type": "integer", + "minimum": 0 + }, + "compacting": { + "type": "integer", + "minimum": 0 + }, + "archived": { + "type": "number" + } + }, + "required": ["created", "updated"], + "additionalProperties": false }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" }, - "data": { + "revert": { "type": "object", "properties": { - "timestamp": { - "type": "number" + "messageID": { + "type": "string" }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "partID": { + "type": "string" }, - "callID": { + "snapshot": { "type": "string" }, - "delta": { + "diff": { "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "delta"] + "required": ["messageID"], + "additionalProperties": false + }, + "project": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProjectSummary" + }, + { + "type": "null" + } + ] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"], + "additionalProperties": false }, - "SyncEvent.session.next.tool.input.ended": { + "McpResource": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, "name": { - "type": "string", - "const": "session.next.tool.input.ended.1" + "type": "string" }, - "id": { + "uri": { "type": "string" }, - "seq": { - "type": "number" + "description": { + "type": "string" }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "mimeType": { + "type": "string" }, - "data": { + "client": { + "type": "string" + } + }, + "required": ["name", "uri", "client"], + "additionalProperties": false + }, + "Symbol": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "kind": { + "type": "integer", + "minimum": 0 + }, + "location": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { + "uri": { "type": "string" }, - "text": { - "type": "string" + "range": { + "$ref": "#/components/schemas/Range" } }, - "required": ["timestamp", "sessionID", "callID", "text"] + "required": ["uri", "range"], + "additionalProperties": false } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["name", "kind", "location"], + "additionalProperties": false }, - "SyncEvent.session.next.tool.called": { + "FileNode": { "type": "object", "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "absolute": { + "type": "string" + }, "type": { "type": "string", - "const": "sync" + "enum": ["file", "directory"] }, - "name": { + "ignored": { + "type": "boolean" + } + }, + "required": ["name", "path", "absolute", "type", "ignored"], + "additionalProperties": false + }, + "FileContent": { + "type": "object", + "properties": { + "type": { "type": "string", - "const": "session.next.tool.called.1" + "enum": ["text", "binary"] }, - "id": { + "content": { "type": "string" }, - "seq": { - "type": "number" - }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "diff": { + "type": "string" }, - "data": { + "patch": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "oldFileName": { + "type": "string" }, - "callID": { + "newFileName": { "type": "string" }, - "tool": { + "oldHeader": { "type": "string" }, - "input": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + "newHeader": { + "type": "string" }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" + "hunks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oldStart": { + "type": "integer", + "minimum": 0 }, - "additionalProperties": {} - } - }, - "required": ["executed"] + "oldLines": { + "type": "integer", + "minimum": 0 + }, + "newStart": { + "type": "integer", + "minimum": 0 + }, + "newLines": { + "type": "integer", + "minimum": 0 + }, + "lines": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"], + "additionalProperties": false + } + }, + "index": { + "type": "string" } }, - "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"] + "required": ["oldFileName", "newFileName", "hunks"], + "additionalProperties": false + }, + "encoding": { + "type": "string", + "enum": ["base64"] + }, + "mimeType": { + "type": "string" } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["type", "content"], + "additionalProperties": false }, - "SyncEvent.session.next.tool.progress": { + "File": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.tool.progress.1" - }, - "id": { + "path": { "type": "string" }, - "seq": { - "type": "number" + "added": { + "type": "integer", + "minimum": 0 }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "removed": { + "type": "integer", + "minimum": 0 }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/Tool.TextContent" - }, - { - "$ref": "#/components/schemas/Tool.FileContent" - } - ] - } - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content"] + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["path", "added", "removed", "status"], + "additionalProperties": false }, - "SyncEvent.session.next.tool.success": { + "Path": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, - "name": { - "type": "string", - "const": "session.next.tool.success.1" + "home": { + "type": "string" }, - "id": { + "state": { "type": "string" }, - "seq": { - "type": "number" + "config": { + "type": "string" }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "worktree": { + "type": "string" }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "content": { - "type": "array", - "items": { - "anyOf": [ - { - "$ref": "#/components/schemas/Tool.TextContent" - }, - { - "$ref": "#/components/schemas/Tool.FileContent" - } - ] - } - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["executed"] - } - }, - "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"] + "directory": { + "type": "string" } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["home", "state", "config", "worktree", "directory"], + "additionalProperties": false }, - "SyncEvent.session.next.tool.error": { + "VcsInfo": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" + "branch": { + "type": "string" }, - "name": { - "type": "string", - "const": "session.next.tool.error.1" + "default_branch": { + "type": "string" + } + }, + "additionalProperties": false + }, + "VcsFileDiff": { + "type": "object", + "properties": { + "file": { + "type": "string" }, - "id": { + "patch": { "type": "string" }, - "seq": { - "type": "number" + "additions": { + "type": "integer", + "minimum": 0 }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "deletions": { + "type": "integer", + "minimum": 0 }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "callID": { - "type": "string" - }, - "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"] - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["executed"] - } - }, - "required": ["timestamp", "sessionID", "callID", "error", "provider"] + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["file", "patch", "additions", "deletions"], + "additionalProperties": false }, - "SyncEvent.session.next.retried": { + "Command": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, "name": { - "type": "string", - "const": "session.next.retried.1" + "type": "string" }, - "id": { + "description": { "type": "string" }, - "seq": { - "type": "number" + "agent": { + "type": "string" }, - "aggregateID": { + "model": { + "type": "string" + }, + "source": { "type": "string", - "const": "sessionID" + "enum": ["command", "mcp", "skill"] }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "attempt": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "error": { - "$ref": "#/components/schemas/session.next.retry_error" - } - }, - "required": ["timestamp", "sessionID", "attempt", "error"] + "template": { + "type": "string" + }, + "subtask": { + "type": "boolean" + }, + "hints": { + "type": "array", + "items": { + "type": "string" + } } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["name", "template", "hints"], + "additionalProperties": false }, - "SyncEvent.session.next.compaction.started": { + "Agent": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, "name": { - "type": "string", - "const": "session.next.compaction.started.1" + "type": "string" }, - "id": { + "description": { "type": "string" }, - "seq": { + "mode": { + "type": "string", + "enum": ["subagent", "primary", "all"] + }, + "native": { + "type": "boolean" + }, + "hidden": { + "type": "boolean" + }, + "topP": { "type": "number" }, - "aggregateID": { - "type": "string", - "const": "sessionID" + "temperature": { + "type": "number" }, - "data": { + "color": { + "type": "string" + }, + "permission": { + "$ref": "#/components/schemas/PermissionRuleset" + }, + "model": { "type": "object", "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "modelID": { + "type": "string" }, - "reason": { - "type": "string", - "enum": ["auto", "manual"] + "providerID": { + "type": "string" } }, - "required": ["timestamp", "sessionID", "reason"] + "required": ["modelID", "providerID"], + "additionalProperties": false + }, + "variant": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "options": { + "type": "object" + }, + "steps": { + "type": "number" } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["name", "mode", "permission", "options"], + "additionalProperties": false }, - "SyncEvent.session.next.compaction.delta": { + "LSPStatus": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" + "id": { + "type": "string" }, "name": { - "type": "string", - "const": "session.next.compaction.delta.1" - }, - "id": { "type": "string" }, - "seq": { - "type": "number" + "root": { + "type": "string" }, - "aggregateID": { + "status": { "type": "string", - "const": "sessionID" - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] + "enum": ["connected", "error"] } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["id", "name", "root", "status"], + "additionalProperties": false }, - "SyncEvent.session.next.compaction.ended": { + "FormatterStatus": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "sync" - }, "name": { - "type": "string", - "const": "session.next.compaction.ended.1" - }, - "id": { "type": "string" }, - "seq": { - "type": "number" + "extensions": { + "type": "array", + "items": { + "type": "string" + } }, - "aggregateID": { + "enabled": { + "type": "boolean" + } + }, + "required": ["name", "extensions", "enabled"], + "additionalProperties": false + }, + "MCPStatusConnected": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["connected"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusDisabled": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["disabled"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusFailed": { + "type": "object", + "properties": { + "status": { "type": "string", - "const": "sessionID" + "enum": ["failed"] }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "text": { - "type": "string" - }, - "include": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "text"] + "error": { + "type": "string" } }, - "required": ["type", "name", "id", "seq", "aggregateID", "data"] + "required": ["status", "error"], + "additionalProperties": false }, - "GlobalEvent": { + "MCPStatusNeedsAuth": { "type": "object", "properties": { - "directory": { + "status": { + "type": "string", + "enum": ["needs_auth"] + } + }, + "required": ["status"], + "additionalProperties": false + }, + "MCPStatusNeedsClientRegistration": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["needs_client_registration"] + }, + "error": { "type": "string" + } + }, + "required": ["status", "error"], + "additionalProperties": false + }, + "MCPStatus": { + "anyOf": [ + { + "$ref": "#/components/schemas/MCPStatusConnected" }, - "project": { + { + "$ref": "#/components/schemas/MCPStatusDisabled" + }, + { + "$ref": "#/components/schemas/MCPStatusFailed" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsAuth" + }, + { + "$ref": "#/components/schemas/MCPStatusNeedsClientRegistration" + } + ] + }, + "McpUnsupportedOAuthError": { + "type": "object", + "properties": { + "error": { "type": "string" + } + }, + "required": ["error"], + "additionalProperties": false + }, + "ProviderAuthMethod": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["oauth", "api"] }, - "workspace": { + "label": { "type": "string" }, - "payload": { - "anyOf": [ - { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, - { - "$ref": "#/components/schemas/Event.lsp.client.diagnostics" - }, - { - "$ref": "#/components/schemas/Event.lsp.updated" - }, - { - "$ref": "#/components/schemas/Event.message.part.delta" - }, - { - "$ref": "#/components/schemas/Event.permission.asked" - }, - { - "$ref": "#/components/schemas/Event.permission.replied" - }, - { - "$ref": "#/components/schemas/Event.session.diff" - }, - { - "$ref": "#/components/schemas/Event.session.error" + "prompts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "placeholder": { + "type": "string" + }, + "when": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "type": "string" + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + } + }, + "required": ["type", "key", "message"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["select"] + }, + "key": { + "type": "string" + }, + "message": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "value": { + "type": "string" + }, + "hint": { + "type": "string" + } + }, + "required": ["label", "value"], + "additionalProperties": false + } + }, + "when": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "op": { + "type": "string", + "enum": ["eq", "neq"] + }, + "value": { + "type": "string" + } + }, + "required": ["key", "op", "value"], + "additionalProperties": false + } + }, + "required": ["type", "key", "message", "options"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["type", "label"], + "additionalProperties": false + }, + "ProviderAuthAuthorization": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "method": { + "type": "string", + "enum": ["auto", "code"] + }, + "instructions": { + "type": "string" + } + }, + "required": ["url", "method", "instructions"], + "additionalProperties": false + }, + "TextPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { + "type": "string" + }, + "synthetic": { + "type": "boolean" + }, + "ignored": { + "type": "boolean" + }, + "time": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0 }, - { - "$ref": "#/components/schemas/Event.installation.updated" + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["start"], + "additionalProperties": false + }, + "metadata": { + "type": "object" + } + }, + "required": ["type", "text"], + "additionalProperties": false + }, + "FilePartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file"] + }, + "mime": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "url": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/FilePartSource" + } + }, + "required": ["type", "mime", "url"], + "additionalProperties": false + }, + "AgentPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["agent"] + }, + "name": { + "type": "string" + }, + "source": { + "type": "object", + "properties": { + "value": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.installation.update-available" + "start": { + "type": "integer", + "minimum": 0 }, - { - "$ref": "#/components/schemas/Event.question.asked" + "end": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["value", "start", "end"], + "additionalProperties": false + } + }, + "required": ["type", "name"], + "additionalProperties": false + }, + "SubtaskPartInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["subtask"] + }, + "prompt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { + "type": "object", + "properties": { + "providerID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.question.replied" + "modelID": { + "type": "string" + } + }, + "required": ["providerID", "modelID"], + "additionalProperties": false + }, + "command": { + "type": "string" + } + }, + "required": ["type", "prompt", "description", "agent"], + "additionalProperties": false + }, + "V2SessionsResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionInfo" + } + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.question.rejected" + "next": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["items", "cursor"], + "additionalProperties": false + }, + "V2SessionMessagesResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionMessage" + } + }, + "cursor": { + "type": "object", + "properties": { + "previous": { + "type": "string" }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "required": ["items", "cursor"], + "additionalProperties": false + }, + "EventTuiPromptAppend": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, + "properties": { + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": ["text"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiCommandExecute": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, + "properties": { + "type": "object", + "properties": { + "command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "session.list", + "session.new", + "session.share", + "session.interrupt", + "session.compact", + "session.page.up", + "session.page.down", + "session.line.up", + "session.line.down", + "session.half.page.up", + "session.half.page.down", + "session.first", + "session.last", + "prompt.clear", + "prompt.submit", + "agent.cycle" + ] + }, + { + "type": "string" + } + ] + } + }, + "required": ["command"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiToastShow": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "message": { + "type": "string" + }, + "variant": { + "type": "string", + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["message", "variant"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "EventTuiSessionSelect": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "description": "Session ID to navigate to" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + "Workspace": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branch": { + "anyOf": [ { - "$ref": "#/components/schemas/Event.todo.updated" + "type": "string" }, { - "$ref": "#/components/schemas/Event.session.status" + "type": "null" + } + ] + }, + "directory": { + "anyOf": [ + { + "type": "string" }, { - "$ref": "#/components/schemas/Event.session.idle" + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "projectID": { + "type": "string" + } + }, + "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"], + "additionalProperties": false + }, + "SyncEventMessageUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Message" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessageRemoved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.removed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessagePartUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.part.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventMessagePartRemoved": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["message.part.removed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionCreated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.created.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionUpdated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.updated.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspaceID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentID": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "summary": { + "anyOf": [ + { + "type": "object", + "properties": { + "additions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + }, + "files": { + "type": "integer", + "minimum": 0 + }, + "diffs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["additions", "deletions", "files"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "share": { + "type": "object", + "properties": { + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "time": { + "type": "object", + "properties": { + "created": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "updated": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "compacting": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "archived": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "permission": { + "anyOf": [ + { + "$ref": "#/components/schemas/PermissionRuleset" + }, + { + "type": "null" + } + ] + }, + "revert": { + "anyOf": [ + { + "type": "object", + "properties": { + "messageID": { + "type": "string" + }, + "partID": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": ["messageID"], + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionDeleted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.deleted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.compacted" + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.agent.switched.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextModelSwitched": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.model.switched.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.tui.session.select" + "id": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.mcp.tools.changed" + "providerID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.mcp.browser.open.failed" + "variant": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "id", "providerID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextPrompted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.prompted.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.command.executed" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.project.updated" + "prompt": { + "$ref": "#/components/schemas/Prompt" + } + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextSynthetic": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.synthetic.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.vcs.branch.updated" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.workspace.ready" + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextShellStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.shell.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.workspace.failed" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.workspace.restore" + "callID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.workspace.status" + "command": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "command"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextShellEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.shell.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.worktree.ready" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.worktree.failed" + "callID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.pty.created" + "output": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.pty.updated" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.pty.exited" + "agent": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.pty.deleted" + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false }, - { - "$ref": "#/components/schemas/Event.message.updated" + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextStepEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.message.removed" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.message.part.updated" + "finish": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.message.part.removed" + "cost": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.session.created" + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "integer", + "minimum": 0 + }, + "output": { + "type": "integer", + "minimum": 0 + }, + "reasoning": { + "type": "integer", + "minimum": 0 + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "integer", + "minimum": 0 + }, + "write": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false }, - { - "$ref": "#/components/schemas/Event.session.updated" + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.session.deleted" + "sessionID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.session.next.agent.switched" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.model.switched" + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextTextEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.text.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.session.next.prompted" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.synthetic" + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.session.next.shell.started" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.shell.ended" + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.session.next.step.started" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.step.ended" + "reasoningID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.text.started" + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.reasoning.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.session.next.text.delta" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.text.ended" + "reasoningID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.reasoning.started" + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.session.next.reasoning.delta" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.reasoning.ended" + "callID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.tool.input.started" + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.session.next.tool.input.delta" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.tool.input.ended" + "callID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.tool.called" + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolInputEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.input.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.session.next.tool.progress" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.tool.success" + "callID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.tool.error" + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolCalled": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.called.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.session.next.retried" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.compaction.started" + "callID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.compaction.delta" + "tool": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.compaction.ended" + "input": { + "type": "object" }, - { - "$ref": "#/components/schemas/Event.server.connected" + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolProgress": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.progress.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/Event.global.disposed" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.message.updated" + "callID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.message.removed" + "structured": { + "type": "object" }, - { - "$ref": "#/components/schemas/SyncEvent.message.part.updated" + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolSuccess": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.success.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/SyncEvent.message.part.removed" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.created" + "callID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.updated" + "structured": { + "type": "object" }, - { - "$ref": "#/components/schemas/SyncEvent.session.deleted" + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.agent.switched" + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextToolError": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.tool.error.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.model.switched" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.prompted" + "callID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.synthetic" + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.shell.started" + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextRetried": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.retried.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.shell.ended" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.step.started" + "attempt": { + "type": "integer", + "minimum": 0 }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.step.ended" + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" + } + }, + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionStarted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.started.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.text.started" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.text.delta" + "reason": { + "type": "string", + "enum": ["auto", "manual"] + } + }, + "required": ["timestamp", "sessionID", "reason"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionDelta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.delta.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.text.ended" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.started" + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "SyncEventSessionNextCompactionEnded": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.compaction.ended.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.delta" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.reasoning.ended" + "text": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.started" + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, + "EventServerInstanceDisposed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["server.instance.disposed"] + }, + "properties": { + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "required": ["directory"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileEdited": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.edited"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + } + }, + "required": ["file"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventFileWatcherUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.delta" + "event": { + "type": "string", + "enum": ["add", "change", "unlink"] + } + }, + "required": ["file", "event"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventLspClientDiagnostics": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.client.diagnostics"] + }, + "properties": { + "type": "object", + "properties": { + "serverID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.input.ended" + "path": { + "type": "string" + } + }, + "required": ["serverID", "path"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventLspUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["message.part.delta"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.called" + "messageID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.progress" + "partID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.success" + "field": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.tool.error" + "delta": { + "type": "string" + } + }, + "required": ["sessionID", "messageID", "partID", "field", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionAsked": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, + "properties": { + "$ref": "#/components/schemas/PermissionRequest" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPermissionReplied": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.retried" + "requestID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.compaction.started" + "reply": { + "type": "string", + "enum": ["once", "always", "reject"] + } + }, + "required": ["sessionID", "requestID", "reply"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionDiff": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.compaction.delta" + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } + } + }, + "required": ["sessionID", "diff"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionError": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.error"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/SyncEvent.session.next.compaction.ended" + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] } - ] + }, + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventInstallationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["installation.updated"] + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false } }, - "required": ["directory", "payload"] - }, - "LogLevel": { - "description": "Log level", - "type": "string", - "enum": ["DEBUG", "INFO", "WARN", "ERROR"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "ServerConfig": { - "description": "Server configuration for opencode serve and web commands", + "EventInstallationUpdate-available": { "type": "object", "properties": { - "port": { - "description": "Port to listen on", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - "hostname": { - "description": "Hostname to listen on", + "id": { "type": "string" }, - "mdns": { - "description": "Enable mDNS service discovery", - "type": "boolean" + "type": { + "type": "string", + "enum": ["installation.update-available"] }, - "mdnsDomain": { - "description": "Custom domain name for mDNS service (default: opencode.local)", + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventQuestionAsked": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "cors": { - "description": "Additional domains to allow for CORS", - "type": "array", - "items": { - "type": "string" - } + "type": { + "type": "string", + "enum": ["question.asked"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" } - } - }, - "PermissionActionConfig": { - "type": "string", - "enum": ["ask", "allow", "deny"] + }, + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "PermissionObjectConfig": { + "EventQuestionReplied": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionReplied" + } }, - "additionalProperties": { - "$ref": "#/components/schemas/PermissionActionConfig" - } + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "PermissionRuleConfig": { - "anyOf": [ - { - "$ref": "#/components/schemas/PermissionActionConfig" + "EventQuestionRejected": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - { - "$ref": "#/components/schemas/PermissionObjectConfig" + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "properties": { + "$ref": "#/components/schemas/QuestionRejected" } - ] + }, + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "PermissionConfig": { - "anyOf": [ - { - "$ref": "#/components/schemas/PermissionActionConfig" + "EventTodoUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - { + "type": { + "type": "string", + "enum": ["todo.updated"] + }, + "properties": { "type": "object", "properties": { - "read": { - "$ref": "#/components/schemas/PermissionRuleConfig" - }, - "edit": { - "$ref": "#/components/schemas/PermissionRuleConfig" - }, - "glob": { - "$ref": "#/components/schemas/PermissionRuleConfig" - }, - "grep": { - "$ref": "#/components/schemas/PermissionRuleConfig" - }, - "list": { - "$ref": "#/components/schemas/PermissionRuleConfig" - }, - "bash": { - "$ref": "#/components/schemas/PermissionRuleConfig" - }, - "task": { - "$ref": "#/components/schemas/PermissionRuleConfig" - }, - "external_directory": { - "$ref": "#/components/schemas/PermissionRuleConfig" - }, - "todowrite": { - "$ref": "#/components/schemas/PermissionActionConfig" - }, - "question": { - "$ref": "#/components/schemas/PermissionActionConfig" - }, - "webfetch": { - "$ref": "#/components/schemas/PermissionActionConfig" - }, - "websearch": { - "$ref": "#/components/schemas/PermissionActionConfig" - }, - "lsp": { - "$ref": "#/components/schemas/PermissionRuleConfig" - }, - "doom_loop": { - "$ref": "#/components/schemas/PermissionActionConfig" + "sessionID": { + "type": "string" }, - "skill": { - "$ref": "#/components/schemas/PermissionRuleConfig" + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } } }, - "additionalProperties": { - "$ref": "#/components/schemas/PermissionRuleConfig" - } + "required": ["sessionID", "todos"], + "additionalProperties": false } - ] + }, + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "AgentConfig": { + "EventSessionStatus": { "type": "object", "properties": { - "model": { - "type": "string" - }, - "variant": { - "description": "Default model variant for this agent (applies only when using the agent's configured model).", + "id": { "type": "string" }, - "temperature": { - "type": "number" - }, - "top_p": { - "type": "number" - }, - "prompt": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.status"] }, - "tools": { - "description": "@deprecated Use 'permission' field instead", + "properties": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "sessionID": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } }, - "additionalProperties": { - "type": "boolean" - } - }, - "disable": { - "type": "boolean" - }, - "description": { - "description": "Description of when to use the agent", + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionIdle": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "mode": { + "type": { "type": "string", - "enum": ["subagent", "primary", "all"] - }, - "hidden": { - "description": "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)", - "type": "boolean" + "enum": ["session.idle"] }, - "options": { + "properties": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "sessionID": { + "type": "string" + } }, - "additionalProperties": {} + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCompacted": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "color": { - "description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)", - "anyOf": [ - { - "type": "string", - "pattern": "^#[0-9a-fA-F]{6}$" - }, - { - "type": "string", - "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"] - } - ] + "type": { + "type": "string", + "enum": ["session.compacted"] }, - "steps": { - "description": "Maximum number of agentic iterations before forcing text-only response", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMcpToolsChanged": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "maxSteps": { - "description": "@deprecated Use 'steps' field instead.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] }, - "permission": { - "$ref": "#/components/schemas/PermissionConfig" + "properties": { + "type": "object", + "properties": { + "server": { + "type": "string" + } + }, + "required": ["server"], + "additionalProperties": false } }, - "additionalProperties": {} + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "ProviderConfig": { + "EventMcpBrowserOpenFailed": { "type": "object", "properties": { - "api": { - "type": "string" - }, - "name": { + "id": { "type": "string" }, - "env": { - "type": "array", - "items": { - "type": "string" - } + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] }, + "properties": { + "type": "object", + "properties": { + "mcpName": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["mcpName", "url"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventCommandExecuted": { + "type": "object", + "properties": { "id": { "type": "string" }, - "npm": { - "type": "string" - }, - "whitelist": { - "type": "array", - "items": { - "type": "string" - } - }, - "blacklist": { - "type": "array", - "items": { - "type": "string" - } + "type": { + "type": "string", + "enum": ["command.executed"] }, - "options": { + "properties": { "type": "object", "properties": { - "apiKey": { + "name": { "type": "string" }, - "baseURL": { + "sessionID": { "type": "string" }, - "enterpriseUrl": { - "description": "GitHub Enterprise URL for copilot authentication", + "arguments": { "type": "string" }, - "setCacheKey": { - "description": "Enable promptCacheKey for this provider (default false)", - "type": "boolean" - }, - "timeout": { - "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", - "anyOf": [ - { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 - }, - { - "type": "boolean", - "const": false - } - ] - }, - "chunkTimeout": { - "description": "Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "messageID": { + "type": "string" } }, - "additionalProperties": {} + "required": ["name", "sessionID", "arguments", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventProjectUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "models": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "family": { - "type": "string" - }, - "release_date": { - "type": "string" - }, - "attachment": { - "type": "boolean" - }, - "reasoning": { - "type": "boolean" - }, - "temperature": { - "type": "boolean" - }, - "tool_call": { - "type": "boolean" - }, - "interleaved": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "object", - "properties": { - "field": { - "type": "string", - "enum": ["reasoning_content", "reasoning_details"] - } - }, - "required": ["field"] - } - ] - }, - "cost": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache_read": { - "type": "number" - }, - "cache_write": { - "type": "number" - }, - "context_over_200k": { - "type": "object", - "properties": { - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache_read": { - "type": "number" - }, - "cache_write": { - "type": "number" - } - }, - "required": ["input", "output"] - } - }, - "required": ["input", "output"] - }, - "limit": { - "type": "object", - "properties": { - "context": { - "type": "number" - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - } - }, - "required": ["context", "output"] - }, - "modalities": { - "type": "object", - "properties": { - "input": { - "type": "array", - "items": { - "type": "string", - "enum": ["text", "audio", "image", "video", "pdf"] - } - }, - "output": { - "type": "array", - "items": { - "type": "string", - "enum": ["text", "audio", "image", "video", "pdf"] - } - } - }, - "required": ["input", "output"] - }, - "experimental": { - "type": "boolean" - }, - "status": { - "type": "string", - "enum": ["alpha", "beta", "deprecated"] - }, - "provider": { - "type": "object", - "properties": { - "npm": { - "type": "string" - }, - "api": { - "type": "string" - } - } - }, - "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "variants": { - "description": "Variant-specific configuration", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "disabled": { - "description": "Disable this variant for the model", - "type": "boolean" - } - }, - "additionalProperties": {} - } - } + "type": { + "type": "string", + "enum": ["project.updated"] + }, + "properties": { + "$ref": "#/components/schemas/Project" + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventVcsBranchUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, + "properties": { + "type": "object", + "properties": { + "branch": { + "type": "string" } - } + }, + "additionalProperties": false } - } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "McpLocalConfig": { + "EventWorkspaceReady": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { - "description": "Type of MCP server connection", "type": "string", - "const": "local" - }, - "command": { - "description": "Command and arguments to run the MCP server", - "type": "array", - "items": { - "type": "string" - } + "enum": ["workspace.ready"] }, - "environment": { - "description": "Environment variables to set when running the MCP server", + "properties": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "name": { + "type": "string" + } }, - "additionalProperties": { - "type": "string" - } - }, - "enabled": { - "description": "Enable or disable the MCP server on startup", - "type": "boolean" - }, - "timeout": { - "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "required": ["name"], + "additionalProperties": false } }, - "required": ["type", "command"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "McpOAuthConfig": { + "EventWorkspaceFailed": { "type": "object", "properties": { - "clientId": { - "description": "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.", - "type": "string" - }, - "clientSecret": { - "description": "OAuth client secret (if required by the authorization server)", + "id": { "type": "string" }, - "scope": { - "description": "OAuth scopes to request during authorization", - "type": "string" + "type": { + "type": "string", + "enum": ["workspace.failed"] }, - "redirectUri": { - "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", - "type": "string" + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "additionalProperties": false } - } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "McpRemoteConfig": { + "EventWorkspaceRestore": { "type": "object", "properties": { - "type": { - "description": "Type of MCP server connection", - "type": "string", - "const": "remote" - }, - "url": { - "description": "URL of the remote MCP server", + "id": { "type": "string" }, - "enabled": { - "description": "Enable or disable the MCP server on startup", - "type": "boolean" + "type": { + "type": "string", + "enum": ["workspace.restore"] }, - "headers": { - "description": "Headers to send with the request", + "properties": { "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "oauth": { - "description": "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.", - "anyOf": [ - { - "$ref": "#/components/schemas/McpOAuthConfig" + "properties": { + "workspaceID": { + "type": "string" }, - { - "type": "boolean", - "const": false + "sessionID": { + "type": "string" + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "step": { + "type": "integer", + "minimum": 0 } - ] - }, - "timeout": { - "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + }, + "required": ["workspaceID", "sessionID", "total", "step"], + "additionalProperties": false } }, - "required": ["type", "url"] - }, - "LayoutConfig": { - "description": "@deprecated Always uses stretch layout.", - "type": "string", - "enum": ["auto", "stretch"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "Config": { + "EventWorkspaceStatus": { "type": "object", "properties": { - "$schema": { - "description": "JSON schema reference for configuration validation", - "type": "string" - }, - "shell": { - "description": "Default shell to use for terminal and bash tool", + "id": { "type": "string" }, - "logLevel": { - "$ref": "#/components/schemas/LogLevel" - }, - "server": { - "$ref": "#/components/schemas/ServerConfig" - }, - "command": { - "description": "Command configuration, see https://opencode.ai/docs/commands", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "template": { - "type": "string" - }, - "description": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { - "type": "string" - }, - "subtask": { - "type": "boolean" - } - }, - "required": ["template"] - } + "type": { + "type": "string", + "enum": ["workspace.status"] }, - "skills": { - "description": "Additional skill folder paths", + "properties": { "type": "object", "properties": { - "paths": { - "description": "Additional paths to skill folders", - "type": "array", - "items": { - "type": "string" - } + "workspaceID": { + "type": "string" }, - "urls": { - "description": "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", - "type": "array", - "items": { - "type": "string" - } + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] } - } + }, + "required": ["workspaceID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeReady": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "watcher": { + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, + "properties": { "type": "object", "properties": { - "ignore": { - "type": "array", - "items": { - "type": "string" - } + "name": { + "type": "string" + }, + "branch": { + "type": "string" } - } - }, - "snapshot": { - "description": "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", - "type": "boolean" - }, - "plugin": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "prefixItems": [ - { - "type": "string" - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - ] - } - ] - } + }, + "required": ["name", "branch"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventWorktreeFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "share": { - "description": "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", + "type": { "type": "string", - "enum": ["manual", "auto", "disabled"] - }, - "autoshare": { - "description": "@deprecated Use 'share' field instead. Share newly created sessions automatically", - "type": "boolean" + "enum": ["worktree.failed"] }, - "autoupdate": { - "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "notify" + "properties": { + "type": "object", + "properties": { + "message": { + "type": "string" } - ] - }, - "disabled_providers": { - "description": "Disable providers that are loaded automatically", - "type": "array", - "items": { - "type": "string" - } - }, - "enabled_providers": { - "description": "When set, ONLY these providers will be enabled. All other providers will be ignored", - "type": "array", - "items": { - "type": "string" - } - }, - "model": { - "description": "Model to use in the format of provider/model, eg anthropic/claude-2", + }, + "required": ["message"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyCreated": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "small_model": { - "description": "Small model to use for tasks like title generation in the format of provider/model", - "type": "string" + "type": { + "type": "string", + "enum": ["pty.created"] }, - "default_agent": { - "description": "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", + "properties": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyUpdated": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "username": { - "description": "Custom username to display in conversations instead of system username", - "type": "string" + "type": { + "type": "string", + "enum": ["pty.updated"] }, - "mode": { - "description": "@deprecated Use `agent` field instead.", + "properties": { "type": "object", "properties": { - "build": { - "$ref": "#/components/schemas/AgentConfig" - }, - "plan": { - "$ref": "#/components/schemas/AgentConfig" + "info": { + "$ref": "#/components/schemas/Pty" } }, - "additionalProperties": { - "$ref": "#/components/schemas/AgentConfig" - } + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyExited": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "agent": { - "description": "Agent configuration, see https://opencode.ai/docs/agents", + "type": { + "type": "string", + "enum": ["pty.exited"] + }, + "properties": { "type": "object", "properties": { - "plan": { - "$ref": "#/components/schemas/AgentConfig" - }, - "build": { - "$ref": "#/components/schemas/AgentConfig" - }, - "general": { - "$ref": "#/components/schemas/AgentConfig" - }, - "explore": { - "$ref": "#/components/schemas/AgentConfig" - }, - "title": { - "$ref": "#/components/schemas/AgentConfig" - }, - "summary": { - "$ref": "#/components/schemas/AgentConfig" + "id": { + "type": "string" }, - "compaction": { - "$ref": "#/components/schemas/AgentConfig" + "exitCode": { + "type": "integer", + "minimum": 0 } }, - "additionalProperties": { - "$ref": "#/components/schemas/AgentConfig" - } + "required": ["id", "exitCode"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventPtyDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "provider": { - "description": "Custom provider configurations and model overrides", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/ProviderConfig" - } + "type": { + "type": "string", + "enum": ["pty.deleted"] }, - "mcp": { - "description": "MCP (Model Context Protocol) server configurations", + "properties": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "id": { + "type": "string" + } }, - "additionalProperties": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/components/schemas/McpLocalConfig" - }, - { - "$ref": "#/components/schemas/McpRemoteConfig" - } - ] - }, - { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": ["enabled"] - } - ] - } + "required": ["id"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "formatter": { - "description": "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "disabled": { - "type": "boolean" - }, - "command": { - "type": "array", - "items": { - "type": "string" - } - }, - "environment": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "extensions": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - ] + "type": { + "type": "string", + "enum": ["message.updated"] }, - "lsp": { - "description": "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", - "anyOf": [ - { - "type": "boolean" + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "anyOf": [ - { - "type": "object", - "properties": { - "disabled": { - "type": "boolean", - "const": true - } - }, - "required": ["disabled"] - }, - { - "type": "object", - "properties": { - "command": { - "type": "array", - "items": { - "type": "string" - } - }, - "extensions": { - "type": "array", - "items": { - "type": "string" - } - }, - "disabled": { - "type": "boolean" - }, - "env": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "initialization": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - } - }, - "required": ["command"] - } - ] - } + "info": { + "$ref": "#/components/schemas/Message" } - ] - }, - "instructions": { - "description": "Additional instruction files or patterns to include", - "type": "array", - "items": { - "type": "string" - } - }, - "layout": { - "$ref": "#/components/schemas/LayoutConfig" - }, - "permission": { - "$ref": "#/components/schemas/PermissionConfig" - }, - "tools": { - "type": "object", - "propertyNames": { - "type": "string" }, - "additionalProperties": { - "type": "boolean" - } + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessageRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "enterprise": { + "type": { + "type": "string", + "enum": ["message.removed"] + }, + "properties": { "type": "object", "properties": { - "url": { - "description": "Enterprise URL", + "sessionID": { + "type": "string" + }, + "messageID": { "type": "string" } - } + }, + "required": ["sessionID", "messageID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "tool_output": { - "description": "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.", + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, + "properties": { "type": "object", "properties": { - "max_lines": { - "description": "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "sessionID": { + "type": "string" }, - "max_bytes": { - "description": "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)", + "part": { + "$ref": "#/components/schemas/Part" + }, + "time": { "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "minimum": 0 } - } + }, + "required": ["sessionID", "part", "time"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventMessagePartRemoved": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "compaction": { + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, + "properties": { "type": "object", "properties": { - "auto": { - "description": "Enable automatic compaction when context is full (default: true)", - "type": "boolean" - }, - "prune": { - "description": "Enable pruning of old tool outputs (default: true)", - "type": "boolean" - }, - "tail_turns": { - "description": "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "sessionID": { + "type": "string" }, - "preserve_recent_tokens": { - "description": "Maximum number of tokens from recent turns to preserve verbatim after compaction", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "messageID": { + "type": "string" }, - "reserved": { - "description": "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "partID": { + "type": "string" } - } + }, + "required": ["sessionID", "messageID", "partID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionCreated": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "experimental": { + "type": { + "type": "string", + "enum": ["session.created"] + }, + "properties": { "type": "object", "properties": { - "disable_paste_summary": { - "type": "boolean" - }, - "batch_tool": { - "description": "Enable the batch tool", - "type": "boolean" - }, - "openTelemetry": { - "description": "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", - "type": "boolean" - }, - "primary_tools": { - "description": "Tools that should only be available to primary agents.", - "type": "array", - "items": { - "type": "string" - } - }, - "continue_loop_on_deny": { - "description": "Continue the agent loop when a tool call is denied", - "type": "boolean" + "sessionID": { + "type": "string" }, - "mcp_timeout": { - "description": "Timeout in milliseconds for model context protocol (MCP) requests", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "info": { + "$ref": "#/components/schemas/Session" } - } + }, + "required": ["sessionID", "info"], + "additionalProperties": false } }, + "required": ["id", "type", "properties"], "additionalProperties": false }, - "BadRequestError": { + "EventSessionUpdated": { "type": "object", "properties": { - "data": {}, - "errors": { - "type": "array", - "items": { - "type": "object", - "propertyNames": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.updated"] + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { "type": "string" }, - "additionalProperties": {} - } - }, - "success": { - "type": "boolean", - "const": false + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false } }, - "required": ["data", "errors", "success"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "OAuth": { + "EventSessionDeleted": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "oauth" + "enum": ["session.deleted"] }, - "refresh": { - "type": "string" - }, - "access": { + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextAgentSwitched": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "expires": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "accountId": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.next.agent.switched"] }, - "enterpriseUrl": { - "type": "string" + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "agent": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "agent"], + "additionalProperties": false } }, - "required": ["type", "refresh", "access", "expires"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "ApiAuth": { + "EventSessionNextModelSwitched": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "const": "api" + "enum": ["session.next.model.switched"] }, - "key": { - "type": "string" - }, - "metadata": { + "properties": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } }, - "additionalProperties": { - "type": "string" - } + "required": ["timestamp", "sessionID", "id", "providerID"], + "additionalProperties": false } }, - "required": ["type", "key"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "WellKnownAuth": { + "PromptSource": { "type": "object", "properties": { - "type": { - "type": "string", - "const": "wellknown" + "start": { + "type": "number" }, - "key": { - "type": "string" + "end": { + "type": "number" }, - "token": { + "text": { "type": "string" } }, - "required": ["type", "key", "token"] + "required": ["start", "end", "text"], + "additionalProperties": false }, - "Auth": { - "anyOf": [ - { - "$ref": "#/components/schemas/OAuth" + "PromptFileAttachment": { + "type": "object", + "properties": { + "uri": { + "type": "string" }, - { - "$ref": "#/components/schemas/ApiAuth" + "mime": { + "type": "string" }, - { - "$ref": "#/components/schemas/WellKnownAuth" + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" + } + }, + "required": ["uri", "mime"], + "additionalProperties": false + }, + "PromptAgentAttachment": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/PromptSource" } - ] + }, + "required": ["name"], + "additionalProperties": false }, - "Workspace": { + "EventSessionNextPrompted": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { "type": "string" }, - "name": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.next.prompted"] }, - "branch": { - "anyOf": [ - { + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { "type": "string" }, - { - "type": "null" + "prompt": { + "$ref": "#/components/schemas/Prompt" } - ] + }, + "required": ["timestamp", "sessionID", "prompt"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "directory": { - "anyOf": [ - { + "type": { + "type": "string", + "enum": ["session.next.synthetic"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { "type": "string" }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" + "text": { + "type": "string" } - ] - }, - "projectID": { - "type": "string" + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false } }, - "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "NotFoundError": { + "EventSessionNextShellStarted": { "type": "object", "properties": { - "name": { + "id": { + "type": "string" + }, + "type": { "type": "string", - "const": "NotFoundError" + "enum": ["session.next.shell.started"] }, - "data": { + "properties": { "type": "object", "properties": { - "message": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "command": { "type": "string" } }, - "required": ["message"] + "required": ["timestamp", "sessionID", "callID", "command"], + "additionalProperties": false } }, - "required": ["name", "data"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "Model": { + "EventSessionNextShellEnded": { "type": "object", "properties": { "id": { "type": "string" }, - "providerID": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.next.shell.ended"] }, - "api": { + "properties": { "type": "object", "properties": { - "id": { + "timestamp": { + "type": "number" + }, + "sessionID": { "type": "string" }, - "url": { + "callID": { "type": "string" }, - "npm": { + "output": { "type": "string" } }, - "required": ["id", "url", "npm"] - }, - "name": { + "required": ["timestamp", "sessionID", "callID", "output"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepStarted": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "family": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.next.step.started"] }, - "capabilities": { + "properties": { "type": "object", "properties": { - "temperature": { - "type": "boolean" - }, - "reasoning": { - "type": "boolean" - }, - "attachment": { - "type": "boolean" + "timestamp": { + "type": "number" }, - "toolcall": { - "type": "boolean" + "sessionID": { + "type": "string" }, - "input": { - "type": "object", - "properties": { - "text": { - "type": "boolean" - }, - "audio": { - "type": "boolean" - }, - "image": { - "type": "boolean" - }, - "video": { - "type": "boolean" - }, - "pdf": { - "type": "boolean" - } - }, - "required": ["text", "audio", "image", "video", "pdf"] + "agent": { + "type": "string" }, - "output": { + "model": { "type": "object", "properties": { - "text": { - "type": "boolean" - }, - "audio": { - "type": "boolean" - }, - "image": { - "type": "boolean" + "id": { + "type": "string" }, - "video": { - "type": "boolean" + "providerID": { + "type": "string" }, - "pdf": { - "type": "boolean" + "variant": { + "type": "string" } }, - "required": ["text", "audio", "image", "video", "pdf"] + "required": ["id", "providerID"], + "additionalProperties": false }, - "interleaved": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "field": { - "type": "string", - "enum": ["reasoning_content", "reasoning_details"] - } - }, - "required": ["field"] - } - ] + "snapshot": { + "type": "string" } }, - "required": ["temperature", "reasoning", "attachment", "toolcall", "input", "output", "interleaved"] + "required": ["timestamp", "sessionID", "agent", "model"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextStepEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "cost": { + "type": { + "type": "string", + "enum": ["session.next.step.ended"] + }, + "properties": { "type": "object", "properties": { - "input": { + "timestamp": { "type": "number" }, - "output": { - "type": "number" + "sessionID": { + "type": "string" }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"] + "finish": { + "type": "string" }, - "experimentalOver200K": { + "cost": { + "type": "number" + }, + "tokens": { "type": "object", "properties": { "input": { - "type": "number" + "type": "integer", + "minimum": 0 }, "output": { - "type": "number" + "type": "integer", + "minimum": 0 + }, + "reasoning": { + "type": "integer", + "minimum": 0 }, "cache": { "type": "object", "properties": { "read": { - "type": "number" + "type": "integer", + "minimum": 0 }, "write": { - "type": "number" + "type": "integer", + "minimum": 0 } }, - "required": ["read", "write"] + "required": ["read", "write"], + "additionalProperties": false } }, - "required": ["input", "output", "cache"] + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "snapshot": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "finish", "cost", "tokens"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextStarted": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.text.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" } }, - "required": ["input", "output", "cache"] + "required": ["timestamp", "sessionID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextDelta": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "limit": { + "type": { + "type": "string", + "enum": ["session.next.text.delta"] + }, + "properties": { "type": "object", "properties": { - "context": { + "timestamp": { "type": "number" }, - "input": { - "type": "number" + "sessionID": { + "type": "string" }, - "output": { - "type": "number" + "delta": { + "type": "string" } }, - "required": ["context", "output"] + "required": ["timestamp", "sessionID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextTextEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "status": { + "type": { "type": "string", - "enum": ["alpha", "beta", "deprecated", "active"] + "enum": ["session.next.text.ended"] }, - "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "headers": { + "properties": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } }, - "additionalProperties": { - "type": "string" - } - }, - "release_date": { + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningStarted": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "variants": { + "type": { + "type": "string", + "enum": ["session.next.reasoning.started"] + }, + "properties": { "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "propertyNames": { + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { "type": "string" }, - "additionalProperties": {} - } + "reasoningID": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "reasoningID"], + "additionalProperties": false } }, - "required": [ - "id", - "providerID", - "api", - "name", - "capabilities", - "cost", - "limit", - "status", - "options", - "headers", - "release_date" - ] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "Provider": { + "EventSessionNextReasoningDelta": { "type": "object", "properties": { "id": { "type": "string" }, - "name": { - "type": "string" - }, - "source": { + "type": { "type": "string", - "enum": ["env", "config", "custom", "api"] + "enum": ["session.next.reasoning.delta"] }, - "env": { - "type": "array", - "items": { - "type": "string" - } - }, - "key": { - "type": "string" - }, - "options": { + "properties": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "delta": { + "type": "string" + } }, - "additionalProperties": {} + "required": ["timestamp", "sessionID", "reasoningID", "delta"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextReasoningEnded": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "models": { + "type": { + "type": "string", + "enum": ["session.next.reasoning.ended"] + }, + "properties": { "type": "object", - "propertyNames": { - "type": "string" + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "reasoningID": { + "type": "string" + }, + "text": { + "type": "string" + } }, - "additionalProperties": { - "$ref": "#/components/schemas/Model" - } + "required": ["timestamp", "sessionID", "reasoningID", "text"], + "additionalProperties": false } }, - "required": ["id", "name", "source", "env", "options", "models"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "ConsoleState": { + "EventSessionNextToolInputStarted": { "type": "object", "properties": { - "consoleManagedProviders": { - "type": "array", - "items": { - "type": "string" - } - }, - "activeOrgName": { + "id": { "type": "string" }, - "switchableOrgCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "type": { + "type": "string", + "enum": ["session.next.tool.input.started"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "name"], + "additionalProperties": false } }, - "required": ["consoleManagedProviders", "switchableOrgCount"] - }, - "ToolIDs": { - "type": "array", - "items": { - "type": "string" - } + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "ToolListItem": { + "EventSessionNextToolInputDelta": { "type": "object", "properties": { "id": { "type": "string" }, - "description": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.next.tool.input.delta"] }, - "parameters": {} + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "delta": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "delta"], + "additionalProperties": false + } }, - "required": ["id", "description", "parameters"] - }, - "ToolList": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ToolListItem" - } + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "Worktree": { + "EventSessionNextToolInputEnded": { "type": "object", "properties": { - "name": { + "id": { "type": "string" }, - "branch": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.next.tool.input.ended"] }, - "directory": { - "type": "string" + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "callID", "text"], + "additionalProperties": false } }, - "required": ["name", "branch", "directory"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "WorktreeCreateInput": { + "EventSessionNextToolCalled": { "type": "object", "properties": { - "name": { + "id": { "type": "string" }, - "startCommand": { - "description": "Additional startup script to run after the project's start command", - "type": "string" - } - } - }, - "WorktreeRemoveInput": { - "type": "object", - "properties": { - "directory": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.next.tool.called"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "input": { + "type": "object" + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "callID", "tool", "input", "provider"], + "additionalProperties": false } }, - "required": ["directory"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "WorktreeResetInput": { + "ToolTextContent": { "type": "object", "properties": { - "directory": { + "type": { + "type": "string", + "enum": ["text"] + }, + "text": { "type": "string" } }, - "required": ["directory"] + "required": ["type", "text"], + "additionalProperties": false }, - "ProjectSummary": { + "ToolFileContent": { "type": "object", "properties": { - "id": { + "type": { + "type": "string", + "enum": ["file"] + }, + "uri": { "type": "string" }, - "name": { + "mime": { "type": "string" }, - "worktree": { + "name": { "type": "string" } }, - "required": ["id", "worktree"] + "required": ["type", "uri", "mime"], + "additionalProperties": false }, - "GlobalSession": { + "EventSessionNextToolProgress": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^ses.*" - }, - "slug": { - "type": "string" - }, - "projectID": { - "type": "string" - }, - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "directory": { - "type": "string" - }, - "path": { "type": "string" }, - "parentID": { + "type": { "type": "string", - "pattern": "^ses.*" + "enum": ["session.next.tool.progress"] }, - "summary": { + "properties": { "type": "object", "properties": { - "additions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "timestamp": { + "type": "number" }, - "deletions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "sessionID": { + "type": "string" }, - "files": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "callID": { + "type": "string" }, - "diffs": { + "structured": { + "type": "object" + }, + "content": { "type": "array", "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] } } }, - "required": ["additions", "deletions", "files"] - }, - "share": { - "type": "object", - "properties": { - "url": { - "type": "string" - } - }, - "required": ["url"] - }, - "title": { + "required": ["timestamp", "sessionID", "callID", "structured", "content"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolSuccess": { + "type": "object", + "properties": { + "id": { "type": "string" }, - "agent": { - "type": "string" + "type": { + "type": "string", + "enum": ["session.next.tool.success"] }, - "model": { + "properties": { "type": "object", "properties": { - "id": { - "type": "string" + "timestamp": { + "type": "number" }, - "providerID": { + "sessionID": { "type": "string" }, - "variant": { + "callID": { "type": "string" - } - }, - "required": ["id", "providerID"] - }, - "version": { - "type": "string" - }, - "time": { - "type": "object", - "properties": { - "created": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 }, - "updated": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "structured": { + "type": "object" }, - "compacting": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } }, - "archived": { - "type": "number" + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false } }, - "required": ["created", "updated"] + "required": ["timestamp", "sessionID", "callID", "structured", "content", "provider"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventSessionNextToolError": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" + "type": { + "type": "string", + "enum": ["session.next.tool.error"] }, - "revert": { + "properties": { "type": "object", "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "partID": { - "type": "string", - "pattern": "^prt.*" + "timestamp": { + "type": "number" }, - "snapshot": { + "sessionID": { "type": "string" }, - "diff": { + "callID": { "type": "string" - } - }, - "required": ["messageID"] - }, - "project": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProjectSummary" }, - { - "type": "null" + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false } - ] - } - }, - "required": ["id", "slug", "projectID", "directory", "title", "version", "time", "project"] - }, - "McpResource": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "uri": { - "type": "string" - }, - "description": { - "type": "string" - }, - "mimeType": { - "type": "string" - }, - "client": { - "type": "string" + }, + "required": ["timestamp", "sessionID", "callID", "error", "provider"], + "additionalProperties": false } }, - "required": ["name", "uri", "client"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "TextPartInput": { + "SessionNextRetry_error": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "text" - }, - "text": { + "message": { "type": "string" }, - "synthetic": { - "type": "boolean" + "statusCode": { + "type": "integer", + "minimum": 0 }, - "ignored": { + "isRetryable": { "type": "boolean" }, - "time": { + "responseHeaders": { "type": "object", - "properties": { - "start": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["start"] + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" }, "metadata": { "type": "object", - "propertyNames": { + "additionalProperties": { "type": "string" - }, - "additionalProperties": {} + } } }, - "required": ["type", "text"] + "required": ["message", "isRetryable"], + "additionalProperties": false }, - "FilePartInput": { + "EventSessionNextRetried": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" - }, - "type": { - "type": "string", - "const": "file" - }, - "mime": { - "type": "string" - }, - "filename": { - "type": "string" - }, - "url": { "type": "string" }, - "source": { - "$ref": "#/components/schemas/FilePartSource" - } - }, - "required": ["type", "mime", "url"] - }, - "AgentPartInput": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^prt.*" - }, "type": { "type": "string", - "const": "agent" - }, - "name": { - "type": "string" + "enum": ["session.next.retried"] }, - "source": { + "properties": { "type": "object", "properties": { - "value": { + "timestamp": { + "type": "number" + }, + "sessionID": { "type": "string" }, - "start": { + "attempt": { "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "minimum": 0 }, - "end": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "error": { + "$ref": "#/components/schemas/SessionNextRetry_error" } }, - "required": ["value", "start", "end"] + "required": ["timestamp", "sessionID", "attempt", "error"], + "additionalProperties": false } }, - "required": ["type", "name"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "SubtaskPartInput": { + "EventSessionNextCompactionStarted": { "type": "object", "properties": { "id": { - "type": "string", - "pattern": "^prt.*" + "type": "string" }, "type": { "type": "string", - "const": "subtask" + "enum": ["session.next.compaction.started"] }, - "prompt": { - "type": "string" - }, - "description": { - "type": "string" - }, - "agent": { - "type": "string" - }, - "model": { + "properties": { "type": "object", "properties": { - "providerID": { - "type": "string" + "timestamp": { + "type": "number" }, - "modelID": { + "sessionID": { "type": "string" + }, + "reason": { + "type": "string", + "enum": ["auto", "manual"] } }, - "required": ["providerID", "modelID"] - }, - "command": { - "type": "string" + "required": ["timestamp", "sessionID", "reason"], + "additionalProperties": false } }, - "required": ["type", "prompt", "description", "agent"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "ProviderAuthMethod": { + "EventSessionNextCompactionDelta": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", - "enum": ["oauth", "api"] - }, - "label": { - "type": "string" + "enum": ["session.next.compaction.delta"] }, - "prompts": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "text" - }, - "key": { - "type": "string" - }, - "message": { - "type": "string" - }, - "placeholder": { - "type": "string" - }, - "when": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "op": { - "type": "string", - "enum": ["eq", "neq"] - }, - "value": { - "type": "string" - } - }, - "required": ["key", "op", "value"] - } - }, - "required": ["type", "key", "message"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "select" - }, - "key": { - "type": "string" - }, - "message": { - "type": "string" - }, - "options": { - "type": "array", - "items": { - "type": "object", - "properties": { - "label": { - "type": "string" - }, - "value": { - "type": "string" - }, - "hint": { - "type": "string" - } - }, - "required": ["label", "value"] - } - }, - "when": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "op": { - "type": "string", - "enum": ["eq", "neq"] - }, - "value": { - "type": "string" - } - }, - "required": ["key", "op", "value"] - } - }, - "required": ["type", "key", "message", "options"] - } - ] - } + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false } }, - "required": ["type", "label"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "ProviderAuthAuthorization": { + "EventSessionNextCompactionEnded": { "type": "object", "properties": { - "url": { + "id": { "type": "string" }, - "method": { + "type": { "type": "string", - "enum": ["auto", "code"] + "enum": ["session.next.compaction.ended"] }, - "instructions": { - "type": "string" + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "text": { + "type": "string" + }, + "include": { + "type": "string" + } + }, + "required": ["timestamp", "sessionID", "text"], + "additionalProperties": false } }, - "required": ["url", "method", "instructions"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "Symbol": { + "EventServerConnected": { "type": "object", "properties": { - "name": { + "id": { "type": "string" }, - "kind": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "type": { + "type": "string", + "enum": ["server.connected"] }, - "location": { + "properties": { "type": "object", - "properties": { - "uri": { - "type": "string" - }, - "range": { - "$ref": "#/components/schemas/Range" - } - }, - "required": ["uri", "range"] + "properties": {} } }, - "required": ["name", "kind", "location"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "FileNode": { + "EventGlobalDisposed": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "absolute": { + "id": { "type": "string" }, "type": { "type": "string", - "enum": ["file", "directory"] + "enum": ["global.disposed"] }, - "ignored": { - "type": "boolean" + "properties": { + "type": "object", + "properties": {} } }, - "required": ["name", "path", "absolute", "type", "ignored"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "FileContent": { + "SessionInfo": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": ["text", "binary"] + "id": { + "type": "string" }, - "content": { + "parentID": { "type": "string" }, - "diff": { + "projectID": { "type": "string" }, - "patch": { + "workspaceID": { + "type": "string" + }, + "path": { + "type": "string" + }, + "agent": { + "type": "string" + }, + "model": { "type": "object", "properties": { - "oldFileName": { - "type": "string" - }, - "newFileName": { + "id": { "type": "string" }, - "oldHeader": { + "providerID": { "type": "string" }, - "newHeader": { + "variant": { "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" }, - "hunks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "oldStart": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "oldLines": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "newStart": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "newLines": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "lines": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["oldStart", "oldLines", "newStart", "newLines", "lines"] - } + "updated": { + "type": "number" }, - "index": { - "type": "string" + "archived": { + "type": "number" } }, - "required": ["oldFileName", "newFileName", "hunks"] + "required": ["created", "updated"], + "additionalProperties": false }, - "encoding": { - "type": "string", - "const": "base64" - }, - "mimeType": { + "title": { "type": "string" } }, - "required": ["type", "content"] + "required": ["id", "projectID", "time", "title"], + "additionalProperties": false }, - "File": { + "SessionDelivery": { + "type": "string", + "enum": ["immediate", "deferred"] + }, + "SessionMessageAgentSwitched": { "type": "object", "properties": { - "path": { + "id": { "type": "string" }, - "added": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "metadata": { + "type": "object" }, - "removed": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false }, - "status": { + "type": { "type": "string", - "enum": ["added", "deleted", "modified"] + "enum": ["agent-switched"] + }, + "agent": { + "type": "string" } }, - "required": ["path", "added", "removed", "status"] + "required": ["id", "time", "type", "agent"], + "additionalProperties": false }, - "Event": { - "anyOf": [ - { - "$ref": "#/components/schemas/Event.server.instance.disposed" - }, - { - "$ref": "#/components/schemas/Event.file.edited" - }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, - { - "$ref": "#/components/schemas/Event.lsp.client.diagnostics" - }, - { - "$ref": "#/components/schemas/Event.lsp.updated" - }, - { - "$ref": "#/components/schemas/Event.message.part.delta" - }, - { - "$ref": "#/components/schemas/Event.permission.asked" - }, - { - "$ref": "#/components/schemas/Event.permission.replied" - }, - { - "$ref": "#/components/schemas/Event.session.diff" - }, - { - "$ref": "#/components/schemas/Event.session.error" - }, - { - "$ref": "#/components/schemas/Event.installation.updated" - }, - { - "$ref": "#/components/schemas/Event.installation.update-available" - }, - { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" - }, - { - "$ref": "#/components/schemas/Event.todo.updated" - }, - { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" - }, - { - "$ref": "#/components/schemas/Event.session.compacted" - }, - { - "$ref": "#/components/schemas/Event.tui.prompt.append" - }, - { - "$ref": "#/components/schemas/Event.tui.command.execute" - }, - { - "$ref": "#/components/schemas/Event.tui.toast.show" - }, - { - "$ref": "#/components/schemas/Event.tui.session.select" - }, - { - "$ref": "#/components/schemas/Event.mcp.tools.changed" - }, - { - "$ref": "#/components/schemas/Event.mcp.browser.open.failed" - }, - { - "$ref": "#/components/schemas/Event.command.executed" - }, - { - "$ref": "#/components/schemas/Event.project.updated" - }, - { - "$ref": "#/components/schemas/Event.vcs.branch.updated" - }, - { - "$ref": "#/components/schemas/Event.workspace.ready" - }, - { - "$ref": "#/components/schemas/Event.workspace.failed" - }, - { - "$ref": "#/components/schemas/Event.workspace.restore" - }, - { - "$ref": "#/components/schemas/Event.workspace.status" - }, - { - "$ref": "#/components/schemas/Event.worktree.ready" - }, - { - "$ref": "#/components/schemas/Event.worktree.failed" - }, - { - "$ref": "#/components/schemas/Event.pty.created" - }, - { - "$ref": "#/components/schemas/Event.pty.updated" - }, - { - "$ref": "#/components/schemas/Event.pty.exited" - }, - { - "$ref": "#/components/schemas/Event.pty.deleted" - }, - { - "$ref": "#/components/schemas/Event.message.updated" - }, - { - "$ref": "#/components/schemas/Event.message.removed" - }, - { - "$ref": "#/components/schemas/Event.message.part.updated" - }, - { - "$ref": "#/components/schemas/Event.message.part.removed" - }, - { - "$ref": "#/components/schemas/Event.session.created" - }, - { - "$ref": "#/components/schemas/Event.session.updated" - }, - { - "$ref": "#/components/schemas/Event.session.deleted" - }, - { - "$ref": "#/components/schemas/Event.session.next.agent.switched" - }, - { - "$ref": "#/components/schemas/Event.session.next.model.switched" - }, - { - "$ref": "#/components/schemas/Event.session.next.prompted" - }, - { - "$ref": "#/components/schemas/Event.session.next.synthetic" - }, - { - "$ref": "#/components/schemas/Event.session.next.shell.started" - }, - { - "$ref": "#/components/schemas/Event.session.next.shell.ended" + "SessionMessageModelSwitched": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.step.started" + "metadata": { + "type": "object" }, - { - "$ref": "#/components/schemas/Event.session.next.step.ended" + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false }, - { - "$ref": "#/components/schemas/Event.session.next.text.started" + "type": { + "type": "string", + "enum": ["model-switched"] }, - { - "$ref": "#/components/schemas/Event.session.next.text.delta" + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID"], + "additionalProperties": false + } + }, + "required": ["id", "time", "type", "model"], + "additionalProperties": false + }, + "SessionMessageUser": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.text.ended" + "metadata": { + "type": "object" }, - { - "$ref": "#/components/schemas/Event.session.next.reasoning.started" + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false }, - { - "$ref": "#/components/schemas/Event.session.next.reasoning.delta" + "text": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.reasoning.ended" + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } }, - { - "$ref": "#/components/schemas/Event.session.next.tool.input.started" + "agents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptAgentAttachment" + } }, - { - "$ref": "#/components/schemas/Event.session.next.tool.input.delta" + "type": { + "type": "string", + "enum": ["user"] + } + }, + "required": ["id", "time", "text", "type"], + "additionalProperties": false + }, + "SessionMessageSynthetic": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.tool.input.ended" + "metadata": { + "type": "object" }, - { - "$ref": "#/components/schemas/Event.session.next.tool.called" + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false }, - { - "$ref": "#/components/schemas/Event.session.next.tool.progress" + "sessionID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.tool.success" + "text": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.tool.error" + "type": { + "type": "string", + "enum": ["synthetic"] + } + }, + "required": ["id", "time", "sessionID", "text", "type"], + "additionalProperties": false + }, + "SessionMessageShell": { + "type": "object", + "properties": { + "id": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.session.next.retried" + "metadata": { + "type": "object" }, - { - "$ref": "#/components/schemas/Event.session.next.compaction.started" + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false }, - { - "$ref": "#/components/schemas/Event.session.next.compaction.delta" + "type": { + "type": "string", + "enum": ["shell"] }, - { - "$ref": "#/components/schemas/Event.session.next.compaction.ended" + "callID": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.server.connected" + "command": { + "type": "string" }, - { - "$ref": "#/components/schemas/Event.global.disposed" + "output": { + "type": "string" } - ] + }, + "required": ["id", "time", "type", "callID", "command", "output"], + "additionalProperties": false }, - "MCPStatusConnected": { + "SessionMessageAssistantText": { "type": "object", "properties": { - "status": { + "type": { "type": "string", - "const": "connected" + "enum": ["text"] + }, + "text": { + "type": "string" } }, - "required": ["status"] + "required": ["type", "text"], + "additionalProperties": false }, - "MCPStatusDisabled": { + "SessionMessageAssistantReasoning": { "type": "object", "properties": { - "status": { + "type": { "type": "string", - "const": "disabled" + "enum": ["reasoning"] + }, + "id": { + "type": "string" + }, + "text": { + "type": "string" } }, - "required": ["status"] + "required": ["type", "id", "text"], + "additionalProperties": false }, - "MCPStatusFailed": { + "SessionMessageToolStatePending": { "type": "object", "properties": { "status": { "type": "string", - "const": "failed" + "enum": ["pending"] }, - "error": { + "input": { "type": "string" } }, - "required": ["status", "error"] + "required": ["status", "input"], + "additionalProperties": false }, - "MCPStatusNeedsAuth": { + "SessionMessageToolStateRunning": { "type": "object", "properties": { "status": { "type": "string", - "const": "needs_auth" + "enum": ["running"] + }, + "input": { + "type": "object" + }, + "structured": { + "type": "object" + }, + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } } }, - "required": ["status"] + "required": ["status", "input", "structured", "content"], + "additionalProperties": false }, - "MCPStatusNeedsClientRegistration": { + "SessionMessageToolStateCompleted": { "type": "object", "properties": { "status": { "type": "string", - "const": "needs_client_registration" - }, - "error": { - "type": "string" - } - }, - "required": ["status", "error"] - }, - "MCPStatus": { - "anyOf": [ - { - "$ref": "#/components/schemas/MCPStatusConnected" + "enum": ["completed"] }, - { - "$ref": "#/components/schemas/MCPStatusDisabled" + "input": { + "type": "object" }, - { - "$ref": "#/components/schemas/MCPStatusFailed" + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PromptFileAttachment" + } }, - { - "$ref": "#/components/schemas/MCPStatusNeedsAuth" + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } }, - { - "$ref": "#/components/schemas/MCPStatusNeedsClientRegistration" - } - ] - }, - "McpUnsupportedOAuthError": { - "type": "object", - "properties": { - "error": { - "type": "string" + "structured": { + "type": "object" } }, - "required": ["error"] + "required": ["status", "input", "content", "structured"], + "additionalProperties": false }, - "Path": { + "SessionMessageToolStateError": { "type": "object", "properties": { - "home": { - "type": "string" + "status": { + "type": "string", + "enum": ["error"] }, - "state": { - "type": "string" + "input": { + "type": "object" }, - "config": { - "type": "string" + "content": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/ToolTextContent" + }, + { + "$ref": "#/components/schemas/ToolFileContent" + } + ] + } }, - "worktree": { - "type": "string" + "structured": { + "type": "object" }, - "directory": { - "type": "string" + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false } }, - "required": ["home", "state", "config", "worktree", "directory"] + "required": ["status", "input", "content", "structured", "error"], + "additionalProperties": false }, - "VcsInfo": { + "SessionMessageAssistantTool": { "type": "object", "properties": { - "branch": { - "type": "string" + "type": { + "type": "string", + "enum": ["tool"] }, - "default_branch": { - "type": "string" - } - } - }, - "VcsFileDiff": { - "type": "object", - "properties": { - "file": { + "id": { "type": "string" }, - "patch": { + "name": { "type": "string" }, - "additions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "provider": { + "type": "object", + "properties": { + "executed": { + "type": "boolean" + }, + "metadata": { + "type": "object" + } + }, + "required": ["executed"], + "additionalProperties": false }, - "deletions": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageToolStatePending" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateRunning" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateCompleted" + }, + { + "$ref": "#/components/schemas/SessionMessageToolStateError" + } + ] }, - "status": { - "type": "string", - "enum": ["added", "deleted", "modified"] + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "ran": { + "type": "number" + }, + "completed": { + "type": "number" + }, + "pruned": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false } }, - "required": ["file", "patch", "additions", "deletions"] + "required": ["type", "id", "name", "state", "time"], + "additionalProperties": false }, - "Command": { + "SessionMessageAssistant": { "type": "object", "properties": { - "name": { + "id": { "type": "string" }, - "description": { - "type": "string" + "metadata": { + "type": "object" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false + }, + "type": { + "type": "string", + "enum": ["assistant"] }, "agent": { "type": "string" }, "model": { - "type": "string" - }, - "source": { - "type": "string", - "enum": ["command", "mcp", "skill"] - }, - "template": { - "anyOf": [ - { + "type": "object", + "properties": { + "id": { "type": "string" }, - { + "providerID": { + "type": "string" + }, + "variant": { "type": "string" } - ] - }, - "subtask": { - "type": "boolean" + }, + "required": ["id", "providerID"], + "additionalProperties": false }, - "hints": { + "content": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageAssistantText" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistantReasoning" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistantTool" + } + ] } + }, + "snapshot": { + "type": "object", + "properties": { + "start": { + "type": "string" + }, + "end": { + "type": "string" + } + }, + "additionalProperties": false + }, + "finish": { + "type": "string" + }, + "cost": { + "type": "number" + }, + "tokens": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "reasoning": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "reasoning", "cache"], + "additionalProperties": false + }, + "error": { + "type": "string" } }, - "required": ["name", "template", "hints"] + "required": ["id", "time", "type", "agent", "model", "content"], + "additionalProperties": false }, - "Agent": { + "SessionMessageCompaction": { "type": "object", "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "mode": { + "type": { "type": "string", - "enum": ["subagent", "primary", "all"] - }, - "native": { - "type": "boolean" + "enum": ["compaction"] }, - "hidden": { - "type": "boolean" + "reason": { + "type": "string", + "enum": ["auto", "manual"] }, - "topP": { - "type": "number" + "summary": { + "type": "string" }, - "temperature": { - "type": "number" + "include": { + "type": "string" }, - "color": { + "id": { "type": "string" }, - "permission": { - "$ref": "#/components/schemas/PermissionRuleset" + "metadata": { + "type": "object" }, - "model": { + "time": { "type": "object", "properties": { - "modelID": { - "type": "string" - }, - "providerID": { - "type": "string" + "created": { + "type": "number" } }, - "required": ["modelID", "providerID"] + "required": ["created"], + "additionalProperties": false + } + }, + "required": ["type", "reason", "summary", "id", "time"], + "additionalProperties": false + }, + "SessionMessage": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionMessageAgentSwitched" }, - "variant": { - "type": "string" + { + "$ref": "#/components/schemas/SessionMessageModelSwitched" }, - "prompt": { - "type": "string" + { + "$ref": "#/components/schemas/SessionMessageUser" }, - "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} + { + "$ref": "#/components/schemas/SessionMessageSynthetic" }, - "steps": { - "type": "number" + { + "$ref": "#/components/schemas/SessionMessageShell" + }, + { + "$ref": "#/components/schemas/SessionMessageAssistant" + }, + { + "$ref": "#/components/schemas/SessionMessageCompaction" } - }, - "required": ["name", "mode", "permission", "options"] + ] }, - "LSPStatus": { + "EventTuiToastShow1": { "type": "object", "properties": { "id": { "type": "string" }, - "name": { - "type": "string" - }, - "root": { - "type": "string" + "type": { + "type": "string", + "enum": ["tui.toast.show"] }, - "status": { - "anyOf": [ - { - "type": "string", - "const": "connected" + "properties": { + "type": "object", + "properties": { + "title": { + "type": "string" }, - { + "message": { + "type": "string" + }, + "variant": { "type": "string", - "const": "error" + "enum": ["info", "success", "warning", "error"] + }, + "duration": { + "type": "integer", + "exclusiveMinimum": 0 } - ] + }, + "required": ["message", "variant"], + "additionalProperties": false } }, - "required": ["id", "name", "root", "status"] + "required": ["id", "type", "properties"], + "additionalProperties": false }, - "FormatterStatus": { + "BadRequestError": { "type": "object", + "required": ["data", "errors", "success"], "properties": { - "name": { - "type": "string" - }, - "extensions": { + "data": {}, + "errors": { "type": "array", "items": { - "type": "string" + "type": "object", + "additionalProperties": {} } }, - "enabled": { - "type": "boolean" + "success": { + "type": "boolean", + "enum": [false] } - }, - "required": ["name", "extensions", "enabled"] + } + }, + "NotFoundError": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string", + "enum": ["NotFoundError"] + }, + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } + } + } } } - } + }, + "security": [], + "tags": [ + { + "name": "control", + "description": "Control plane routes." + }, + { + "name": "global", + "description": "Global server routes." + }, + { + "name": "event", + "description": "Instance event stream route." + }, + { + "name": "config", + "description": "Experimental HttpApi config routes." + }, + { + "name": "experimental", + "description": "Experimental HttpApi read-only routes." + }, + { + "name": "file", + "description": "Experimental HttpApi file routes." + }, + { + "name": "instance", + "description": "Experimental HttpApi instance read routes." + }, + { + "name": "mcp", + "description": "Experimental HttpApi MCP routes." + }, + { + "name": "project", + "description": "Experimental HttpApi project routes." + }, + { + "name": "pty", + "description": "Experimental HttpApi PTY routes." + }, + { + "name": "question", + "description": "Question routes." + }, + { + "name": "permission", + "description": "Experimental HttpApi permission routes." + }, + { + "name": "provider", + "description": "Experimental HttpApi provider routes." + }, + { + "name": "session", + "description": "Experimental HttpApi session routes." + }, + { + "name": "sync", + "description": "Experimental HttpApi sync routes." + }, + { + "name": "v2", + "description": "Experimental v2 routes." + }, + { + "name": "v2 messages", + "description": "Experimental v2 message routes." + }, + { + "name": "tui", + "description": "Experimental HttpApi TUI routes." + }, + { + "name": "workspace", + "description": "Experimental HttpApi workspace routes." + }, + { + "name": "pty", + "description": "PTY websocket route." + } + ] } From 2ad1eb56d3e0e1088a69e785744d92f27d568768 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 09:09:45 -0400 Subject: [PATCH 110/178] feat(server): native HttpApi listener with Bun.serve + WS upgrade (#25547) --- .../opencode/src/server/httpapi-listener.ts | 244 ++++++++++++++++++ .../test/server/httpapi-listener.test.ts | 109 ++++++++ 2 files changed, 353 insertions(+) create mode 100644 packages/opencode/src/server/httpapi-listener.ts create mode 100644 packages/opencode/test/server/httpapi-listener.test.ts diff --git a/packages/opencode/src/server/httpapi-listener.ts b/packages/opencode/src/server/httpapi-listener.ts new file mode 100644 index 0000000000..fd65b0ae67 --- /dev/null +++ b/packages/opencode/src/server/httpapi-listener.ts @@ -0,0 +1,244 @@ +// TODO: Node adapter forthcoming — same pattern but using `node:http` + `ws` library, +// and `node:http`'s `upgrade` event. +// +// This module is a Bun-only proof-of-concept for a native `Bun.serve` listener that +// drives the experimental HttpApi handler directly (no Hono in the middle) and handles +// WebSocket upgrades inline based on path-matching. It exists to validate the pattern +// before deleting the Hono backend; `Server.listen()` is intentionally NOT wired to it. + +import type { ServerWebSocket } from "bun" +import { Effect, Schema } from "effect" +import { AppRuntime } from "@/effect/app-runtime" +import { WithInstance } from "@/project/with-instance" +import { Pty } from "@/pty" +import { handlePtyInput } from "@/pty/input" +import { PtyID } from "@/pty/schema" +import { PtyPaths } from "@/server/routes/instance/httpapi/groups/pty" +import { ExperimentalHttpApiServer } from "@/server/routes/instance/httpapi/server" +import * as Log from "@opencode-ai/core/util/log" +import type { CorsOptions } from "./cors" + +const log = Log.create({ service: "httpapi-listener" }) +const decodePtyID = Schema.decodeUnknownSync(PtyID) + +export type Listener = { + hostname: string + port: number + url: URL + stop: (close?: boolean) => Promise +} + +export type ListenOptions = CorsOptions & { + port: number + hostname: string +} + +type WsKind = { kind: "pty"; ptyID: string; cursor: number | undefined; directory: string } + +type PtyHandler = { + onMessage: (message: string | ArrayBuffer) => void + onClose: () => void +} + +type WsState = WsKind & { + handler?: PtyHandler + pending: Array + ready: boolean + closed: boolean +} + +// Derive from the OpenAPI path so this stays in sync if the route literal moves. +const ptyConnectPattern = new RegExp(`^${PtyPaths.connect.replace(/:[^/]+/g, "([^/]+)")}$`) + +function parseCursor(value: string | null): number | undefined { + if (!value) return undefined + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < -1) return undefined + return parsed +} + +function asAdapter(ws: ServerWebSocket) { + return { + get readyState() { + return ws.readyState + }, + send: (data: string | Uint8Array | ArrayBuffer) => { + try { + if (data instanceof ArrayBuffer) ws.send(new Uint8Array(data)) + else ws.send(data) + } catch { + // socket likely already closed; ignore + } + }, + close: (code?: number, reason?: string) => { + try { + ws.close(code, reason) + } catch { + // ignore + } + }, + } +} + +/** + * Spin up a native Bun.serve that: + * 1. Routes all HTTP traffic through the HttpApi web handler. + * 2. Intercepts known WebSocket upgrade paths and handles them inline. + * + * This bypasses Hono entirely. The Hono code path remains untouched. + */ +export async function listen(opts: ListenOptions): Promise { + const built = ExperimentalHttpApiServer.webHandler(opts) + const handler = built.handler + const context = ExperimentalHttpApiServer.context + + const start = (port: number) => { + try { + return Bun.serve({ + hostname: opts.hostname, + port, + idleTimeout: 0, + fetch(request, server) { + const url = new URL(request.url) + const ptyMatch = url.pathname.match(ptyConnectPattern) + if (ptyMatch && request.headers.get("upgrade")?.toLowerCase() === "websocket") { + const ptyID = ptyMatch[1]! + const cursor = parseCursor(url.searchParams.get("cursor")) + // Resolve the instance directory the same way the HttpApi + // `instance-context` middleware does (search params, then header, + // then process.cwd()). + const directory = + url.searchParams.get("directory") ?? request.headers.get("x-opencode-directory") ?? process.cwd() + const upgraded = server.upgrade(request, { + data: { + kind: "pty", + ptyID, + cursor, + directory, + pending: [], + ready: false, + closed: false, + } satisfies WsState, + }) + if (upgraded) return undefined + return new Response("upgrade failed", { status: 400 }) + } + + // TODO: workspace-proxy WS upgrade detection. The Hono path forwards via a + // remote `new WebSocket(url, ...)` (see ServerProxy.websocket). To support + // that here we'd need to (a) resolve the workspace target the same way + // `WorkspaceRouterMiddleware` does today, then (b) `server.upgrade(request, + // { data: { kind: "proxy", target, headers, protocols } })` and bridge the + // ServerWebSocket to a remote WebSocket inside the `websocket` handlers. + // Deferred to a follow-up — the proxy story needs more design (auth header + // forwarding, fence sync, reconnection semantics) than fits this PR. + + return handler(request as Request, context as never) + }, + websocket: { + open(ws) { + const data = ws.data + if (data.kind !== "pty") { + ws.close(1011, "unknown ws kind") + return + } + const id = (() => { + try { + return decodePtyID(data.ptyID) + } catch { + ws.close(1008, "invalid pty id") + return undefined + } + })() + if (!id) return + ;(async () => { + const result = await WithInstance.provide({ + directory: data.directory, + fn: () => + AppRuntime.runPromise( + Effect.gen(function* () { + const pty = yield* Pty.Service + return yield* pty.connect(id, asAdapter(ws), data.cursor) + }).pipe(Effect.withSpan("HttpApiListener.pty.connect.open")), + ), + }) + return await result + })() + .then((handler) => { + if (data.closed) { + handler?.onClose() + return + } + if (!handler) { + ws.close(4404, "session not found") + return + } + data.handler = handler + data.ready = true + for (const msg of data.pending) { + AppRuntime.runPromise(handlePtyInput(handler, msg)).catch(() => undefined) + } + data.pending.length = 0 + }) + .catch((err) => { + log.error("pty connect failed", { error: err }) + ws.close(1011, "pty connect failed") + }) + }, + message(ws, message) { + const data = ws.data + if (data.kind !== "pty") return + const payload = + typeof message === "string" + ? message + : message instanceof Buffer + ? new Uint8Array(message.buffer, message.byteOffset, message.byteLength) + : (message as Uint8Array) + if (!data.ready || !data.handler) { + data.pending.push(payload) + return + } + AppRuntime.runPromise(handlePtyInput(data.handler, payload)).catch(() => undefined) + }, + close(ws) { + const data = ws.data + data.closed = true + data.handler?.onClose() + }, + }, + }) + } catch (err) { + log.error("Bun.serve failed", { error: err }) + return undefined + } + } + + const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port) + if (!server) throw new Error(`Failed to start server on port ${opts.port}`) + const port = server.port + if (port === undefined) throw new Error("Bun.serve started without a numeric port") + + const url = new URL("http://localhost") + url.hostname = opts.hostname + url.port = String(port) + + let closing: Promise | undefined + return { + hostname: opts.hostname, + port, + url, + stop(close?: boolean) { + closing ??= (async () => { + await server.stop(close) + // NOTE: we deliberately do NOT call `built.dispose()` here. The + // underlying `webHandler` is memoized at module level (same as the + // Hono path), so disposing it would tear down shared services for + // every other consumer in the process. Lifecycle teardown is owned + // by the AppRuntime itself. + })() + return closing + }, + } +} + +export * as HttpApiListener from "./httpapi-listener" diff --git a/packages/opencode/test/server/httpapi-listener.test.ts b/packages/opencode/test/server/httpapi-listener.test.ts new file mode 100644 index 0000000000..de7b5987ec --- /dev/null +++ b/packages/opencode/test/server/httpapi-listener.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { HttpApiListener } from "../../src/server/httpapi-listener" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" + +void Log.init({ print: false }) + +const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +const testPty = process.platform === "win32" ? test.skip : test + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original + await disposeAllInstances() + await resetDatabase() +}) + +async function startListener() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + return HttpApiListener.listen({ hostname: "127.0.0.1", port: 0 }) +} + +describe("native HttpApi listener", () => { + test("serves HTTP routes via the HttpApi web handler", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const response = await fetch(`${listener.url.origin}${PtyPaths.shells}`, { + headers: { "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(200) + const body = await response.json() + expect(Array.isArray(body)).toBe(true) + expect(body[0]).toMatchObject({ + path: expect.any(String), + name: expect.any(String), + acceptable: expect.any(Boolean), + }) + } finally { + await listener.stop(true) + } + }) + + testPty("PTY websocket connect echoes input back to the client", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const created = await fetch(`${listener.url.origin}${PtyPaths.create}`, { + method: "POST", + headers: { + "x-opencode-directory": tmp.path, + "content-type": "application/json", + }, + body: JSON.stringify({ command: "/bin/cat", title: "listener-smoke" }), + }) + expect(created.status).toBe(200) + const info = (await created.json()) as { id: string } + + try { + const wsURL = new URL(PtyPaths.connect.replace(":ptyID", info.id), listener.url) + wsURL.protocol = "ws:" + wsURL.searchParams.set("directory", tmp.path) + wsURL.searchParams.set("cursor", "-1") + + const messages: string[] = [] + const ws = new WebSocket(wsURL) + ws.binaryType = "arraybuffer" + + const opened = new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }) + ws.addEventListener("error", () => reject(new Error("ws error before open")), { once: true }) + }) + + const closed = new Promise((resolve) => { + ws.addEventListener("close", () => resolve(), { once: true }) + }) + + ws.addEventListener("message", (event) => { + const data = event.data + messages.push(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer)) + }) + + await opened + ws.send("ping-listener\n") + + const start = Date.now() + while (!messages.some((m) => m.includes("ping-listener")) && Date.now() - start < 5_000) { + await new Promise((r) => setTimeout(r, 50)) + } + ws.close(1000, "done") + + expect(messages.some((m) => m.includes("ping-listener"))).toBe(true) + // Verify close event fires (handler.onClose path runs and the + // Bun.serve websocket lifecycle reaches close). + await closed + expect(ws.readyState).toBe(WebSocket.CLOSED) + } finally { + await fetch(`${listener.url.origin}${PtyPaths.remove.replace(":ptyID", info.id)}`, { + method: "DELETE", + headers: { "x-opencode-directory": tmp.path }, + }).catch(() => undefined) + } + } finally { + await listener.stop(true) + } + }) +}) From 7a503de606888939a64776c512ca4588267bbd8d Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 3 May 2026 18:42:24 +0530 Subject: [PATCH 111/178] fix(acp): pass server auth to internal client (#25591) --- packages/opencode/src/cli/cmd/acp.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 251c608843..1bf52a0c8f 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -6,6 +6,7 @@ import { ACP } from "@/acp/agent" import { Server } from "@/server/server" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" +import { Flag } from "@opencode-ai/core/flag/flag" const log = Log.create({ service: "acp-command" }) @@ -26,6 +27,13 @@ export const AcpCommand = effectCmd({ const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, + headers: Flag.OPENCODE_SERVER_PASSWORD + ? { + Authorization: `Basic ${Buffer.from( + `${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`, + ).toString("base64")}`, + } + : undefined, }) const input = new WritableStream({ From 379600b5ab9ed46043d1674e7fb7c3dbcb9bd4ba Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 09:17:06 -0400 Subject: [PATCH 112/178] fix(sdk+cli): surface real errors instead of bare {} when server returns empty body (#25592) --- packages/opencode/src/util/error.ts | 27 ++++++++++++++--------- packages/opencode/test/util/error.test.ts | 13 +++++++++++ packages/sdk/js/src/v2/client.ts | 19 ++++++++++++++++ 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index fbda2dc50e..32936e9935 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -7,7 +7,19 @@ export function errorFormat(error: unknown): string { if (typeof error === "object" && error !== null) { try { - return JSON.stringify(error, null, 2) + const json = JSON.stringify(error, null, 2) + // Plain objects whose own properties are all non-enumerable (or empty) + // serialize to "{}", which prints as a useless bare `{}` on stderr. + // Fall back to a custom toString first, then to ctor name + own prop names. + if (json === "{}") { + const str = String(error) + if (str && str !== "[object Object]") return str + const ctor = error.constructor?.name + const prefix = ctor && ctor !== "Object" ? ctor : "Error" + const names = Object.getOwnPropertyNames(error) + return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }` + } + return json } catch { return "Unexpected error (unserializable)" } @@ -34,7 +46,7 @@ export function errorMessage(error: unknown): string { if (text && text !== "[object Object]") return text const formatted = errorFormat(error) - if (formatted && formatted !== "{}") return formatted + if (formatted) return formatted return "unknown error" } @@ -45,7 +57,7 @@ export function errorData(error: unknown) { message: errorMessage(error), stack: error.stack, cause: error.cause === undefined ? undefined : errorFormat(error.cause), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -53,7 +65,7 @@ export function errorData(error: unknown) { return { type: typeof error, message: errorMessage(error), - formatted: errorFormatted(error), + formatted: errorFormat(error), } } @@ -71,12 +83,7 @@ export function errorData(error: unknown) { if (typeof data.message !== "string") data.message = errorMessage(error) if (typeof data.type !== "string") data.type = error.constructor?.name - data.formatted = errorFormatted(error) + data.formatted = errorFormat(error) return data } -function errorFormatted(error: unknown) { - const formatted = errorFormat(error) - if (formatted !== "{}") return formatted - return String(error) -} diff --git a/packages/opencode/test/util/error.test.ts b/packages/opencode/test/util/error.test.ts index e536f3c4ea..e7a02d6151 100644 --- a/packages/opencode/test/util/error.test.ts +++ b/packages/opencode/test/util/error.test.ts @@ -22,6 +22,19 @@ describe("util.error", () => { expect(data.code).toBe("E_BAD") }) + test("never returns bare {} for opaque object errors", () => { + // Plain empty object — what the SDK threw before we wrapped it. + expect(errorFormat({})).not.toBe("{}") + expect(errorFormat({})).toContain("no message") + + // Object with only non-enumerable own properties (JSON.stringify drops them). + class OpaqueError {} + const opaque = new OpaqueError() + Object.defineProperty(opaque, "secret", { value: "hidden", enumerable: false }) + expect(errorFormat(opaque)).not.toBe("{}") + expect(errorFormat(opaque)).toContain("OpaqueError") + }) + test("handles opaque throwables with custom toString", () => { const err = { toString() { diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 2d71d8446d..8b49e7f101 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -84,5 +84,24 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp return response }) + // The generated client falls back to throwing a literal `{}` when the server + // responds with an empty / unparseable error body, which surfaces as a bare + // `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so + // downstream formatters get a useful message — but pass through any parsed + // JSON error body unchanged so existing consumers can still inspect fields. + client.interceptors.error.use((error, response, request) => { + const isEmpty = + error === undefined || + error === null || + error === "" || + (typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0) + if (!isEmpty) return error + const method = request?.method ?? "?" + const url = request?.url ?? "?" + if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`) + const status = response.status + const statusText = response.statusText ? " " + response.statusText : "" + return new Error(`opencode server ${method} ${url} → ${status}${statusText}: (empty response body)`) + }) return new OpencodeClient({ client }) } From 8433e8b43333232e464f618daf542ace43442b6d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 13:18:13 +0000 Subject: [PATCH 113/178] chore: generate --- packages/opencode/src/util/error.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index 32936e9935..dabc6dfe18 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -86,4 +86,3 @@ export function errorData(error: unknown) { data.formatted = errorFormat(error) return data } - From 101566131d15dbe73e9d246d3d35da767f28cd80 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sun, 3 May 2026 15:20:05 +0200 Subject: [PATCH 114/178] fix(httpapi): add basic auth challenge for browser login Adds a WWW-Authenticate challenge for unauthorized experimental HttpApi UI fallback responses so browsers open the Basic Auth prompt when a server password is configured. --- .../routes/instance/httpapi/middleware/authorization.ts | 8 +++++++- packages/opencode/test/server/httpapi-ui.test.ts | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index e022a568ac..05b8738971 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -5,6 +5,7 @@ import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstabl const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 +const WWW_AUTHENTICATE = "Basic realm=\"Secure Area\"" export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", @@ -82,7 +83,12 @@ function validateRawCredential( ) { if (!isAuthRequired(config)) return effect if (!isCredentialAuthorized(credential, config)) - return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED })) + return Effect.succeed( + HttpServerResponse.empty({ + status: UNAUTHORIZED, + headers: { "www-authenticate": WWW_AUTHENTICATE }, + }), + ) return effect } diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 09b234bde9..1de8a489cd 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -201,6 +201,7 @@ describe("HttpApi UI fallback", () => { const response = await uiApp({ password: "secret", username: "opencode" }).request("/") expect(response.status).toBe(401) + expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"') }) test("accepts auth token for the web UI", async () => { From fb224d8974e8ab591cb42fb62cc28b32fb261a78 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 13:21:15 +0000 Subject: [PATCH 115/178] chore: generate --- .../server/routes/instance/httpapi/middleware/authorization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 05b8738971..4edd06479b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -5,7 +5,7 @@ import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstabl const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 -const WWW_AUTHENTICATE = "Basic realm=\"Secure Area\"" +const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", From e77867ef058f2e0fde159c5d6fb6b2e575f9f7a7 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 3 May 2026 21:40:15 +0800 Subject: [PATCH 116/178] ci: only build electron desktop (#19067) --- .github/workflows/publish.yml | 219 +++------------- .../electron-builder.config.ts | 2 +- .../desktop/scripts/finalize-latest-json.ts | 233 +++++++++++------- 3 files changed, 179 insertions(+), 275 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9981edad7f..4614226a8a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -209,182 +209,6 @@ jobs: packages/opencode/dist/opencode-windows-x64 packages/opencode/dist/opencode-windows-x64-baseline - build-tauri: - needs: - - build-cli - - version - continue-on-error: false - env: - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }} - AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - strategy: - fail-fast: false - matrix: - settings: - - host: macos-latest - target: x86_64-apple-darwin - - host: macos-latest - target: aarch64-apple-darwin - # github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain - - host: windows-2025 - target: aarch64-pc-windows-msvc - - host: blacksmith-4vcpu-windows-2025 - target: x86_64-pc-windows-msvc - - host: blacksmith-4vcpu-ubuntu-2404 - target: x86_64-unknown-linux-gnu - - host: blacksmith-8vcpu-ubuntu-2404-arm - target: aarch64-unknown-linux-gnu - runs-on: ${{ matrix.settings.host }} - steps: - - uses: actions/checkout@v3 - with: - fetch-tags: true - - - uses: apple-actions/import-codesign-certs@v2 - if: ${{ runner.os == 'macOS' }} - with: - keychain: build - p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }} - p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - - - name: Verify Certificate - if: ${{ runner.os == 'macOS' }} - run: | - CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application") - CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') - echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV - echo "Certificate imported." - - - name: Setup Apple API Key - if: ${{ runner.os == 'macOS' }} - run: | - echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8 - - - uses: ./.github/actions/setup-bun - - - name: Azure login - if: runner.os == 'Windows' - uses: azure/login@v2 - with: - client-id: ${{ env.AZURE_CLIENT_ID }} - tenant-id: ${{ env.AZURE_TENANT_ID }} - subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} - - - uses: actions/setup-node@v4 - with: - node-version: "24" - - - name: Cache apt packages - if: contains(matrix.settings.host, 'ubuntu') - uses: actions/cache@v4 - with: - path: ~/apt-cache - key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.settings.target }}-apt- - - - name: install dependencies (ubuntu only) - if: contains(matrix.settings.host, 'ubuntu') - run: | - mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache - sudo apt-get update - sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - sudo chmod -R a+rw ~/apt-cache - - - name: install Rust stable - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.settings.target }} - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: packages/desktop/src-tauri - shared-key: ${{ matrix.settings.target }} - - - name: Prepare - run: | - cd packages/desktop - bun ./scripts/prepare.ts - env: - OPENCODE_VERSION: ${{ needs.version.outputs.version }} - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} - RUST_TARGET: ${{ matrix.settings.target }} - GH_TOKEN: ${{ github.token }} - GITHUB_RUN_ID: ${{ github.run_id }} - - - name: Resolve tauri portable SHA - if: contains(matrix.settings.host, 'ubuntu') - run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV" - - # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released - - name: Install tauri-cli from portable appimage branch - uses: taiki-e/cache-cargo-install-action@v3 - if: contains(matrix.settings.host, 'ubuntu') - with: - tool: tauri-cli - git: https://github.com/tauri-apps/tauri - # branch: feat/truly-portable-appimage - rev: ${{ env.TAURI_PORTABLE_SHA }} - - - name: Show tauri-cli version - if: contains(matrix.settings.host, 'ubuntu') - run: cargo tauri --version - - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - - name: Build and upload artifacts - uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a - timeout-minutes: 60 - with: - projectPath: packages/desktop - uploadWorkflowArtifacts: true - tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }} - args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose - updaterJsonPreferNsis: true - releaseId: ${{ needs.version.outputs.release }} - tagName: ${{ needs.version.outputs.tag }} - releaseDraft: true - releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] - repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }} - releaseCommitish: ${{ github.sha }} - env: - GITHUB_TOKEN: ${{ steps.committer.outputs.token }} - TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 - - - name: Verify signed Windows desktop artifacts - if: runner.os == 'Windows' - shell: pwsh - run: | - $files = @( - "${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe" - ) - $files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName - - foreach ($file in $files) { - $sig = Get-AuthenticodeSignature $file - if ($sig.Status -ne "Valid") { - throw "Invalid signature for ${file}: $($sig.Status)" - } - } - build-electron: needs: - build-cli @@ -524,6 +348,30 @@ jobs: env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} + - name: Create and upload macOS .app.tar.gz + if: runner.os == 'macOS' && needs.version.outputs.release + working-directory: packages/desktop-electron/dist + env: + GH_TOKEN: ${{ steps.committer.outputs.token }} + run: | + if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then + APP_DIR="mac" + OUT_NAME="opencode-desktop-mac-x64.app.tar.gz" + elif [[ "${{ matrix.settings.target }}" == "aarch64-apple-darwin" ]]; then + APP_DIR="mac-arm64" + OUT_NAME="opencode-desktop-mac-arm64.app.tar.gz" + else + echo "Unknown macOS target: ${{ matrix.settings.target }}" + exit 1 + fi + APP_PATH=$(find "$APP_DIR" -maxdepth 1 -name "*.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "No .app bundle found in $APP_DIR" + exit 1 + fi + tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")" + gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}" + - name: Verify signed Windows Electron artifacts if: runner.os == 'Windows' shell: pwsh @@ -542,7 +390,7 @@ jobs: - uses: actions/upload-artifact@v4 with: - name: opencode-electron-${{ matrix.settings.target }} + name: opencode-desktop-${{ matrix.settings.target }} path: packages/desktop-electron/dist/* - uses: actions/upload-artifact@v4 @@ -556,7 +404,6 @@ jobs: - version - build-cli - sign-cli-windows - - build-tauri - build-electron if: always() && !failure() && !cancelled() runs-on: blacksmith-4vcpu-ubuntu-2404 @@ -583,13 +430,6 @@ jobs: node-version: "24" registry-url: "https://registry.npmjs.org" - - name: Setup git committer - id: committer - uses: ./.github/actions/setup-git-committer - with: - opencode-app-id: ${{ vars.OPENCODE_APP_ID }} - opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - - uses: actions/download-artifact@v4 with: name: opencode-cli @@ -611,6 +451,13 @@ jobs: pattern: latest-yml-* path: /tmp/latest-yml + - name: Setup git committer + id: committer + uses: ./.github/actions/setup-git-committer + with: + opencode-app-id: ${{ vars.OPENCODE_APP_ID }} + opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} + - name: Cache apt packages (AUR) uses: actions/cache@v4 with: @@ -639,3 +486,5 @@ jobs: GH_REPO: ${{ needs.version.outputs.repo }} NPM_CONFIG_PROVENANCE: false LATEST_YML_DIR: /tmp/latest-yml + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts index fa088cd65d..da734dc81d 100644 --- a/packages/desktop-electron/electron-builder.config.ts +++ b/packages/desktop-electron/electron-builder.config.ts @@ -27,7 +27,7 @@ const channel = (() => { })() const getBase = (): Configuration => ({ - artifactName: "opencode-electron-${os}-${arch}.${ext}", + artifactName: "opencode-desktop-${os}-${arch}.${ext}", directories: { output: "dist", buildResources: "resources", diff --git a/packages/desktop/scripts/finalize-latest-json.ts b/packages/desktop/scripts/finalize-latest-json.ts index 855c6a3878..cb0f26b94d 100644 --- a/packages/desktop/scripts/finalize-latest-json.ts +++ b/packages/desktop/scripts/finalize-latest-json.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun -import { Buffer } from "node:buffer" import { $ } from "bun" +import path from "node:path" +import { parseArgs } from "node:util" const { values } = parseArgs({ args: Bun.argv.slice(2), @@ -12,8 +13,6 @@ const { values } = parseArgs({ const dryRun = values["dry-run"] -import { parseArgs } from "node:util" - const repo = process.env.GH_REPO if (!repo) throw new Error("GH_REPO is required") @@ -23,20 +22,22 @@ if (!releaseId) throw new Error("OPENCODE_RELEASE is required") const version = process.env.OPENCODE_VERSION if (!version) throw new Error("OPENCODE_VERSION is required") +const dir = process.env.LATEST_YML_DIR +if (!dir) throw new Error("LATEST_YML_DIR is required") +const root = dir + const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required") -const apiHeaders = { - Authorization: `token ${token}`, - Accept: "application/vnd.github+json", -} - -const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, { - headers: apiHeaders, +const rel = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, { + headers: { + Authorization: `token ${token}`, + Accept: "application/vnd.github+json", + }, }) -if (!releaseRes.ok) { - throw new Error(`Failed to fetch release: ${releaseRes.status} ${releaseRes.statusText}`) +if (!rel.ok) { + throw new Error(`Failed to fetch release: ${rel.status} ${rel.statusText}`) } type Asset = { @@ -45,115 +46,169 @@ type Asset = { } type Release = { - tag_name?: string assets?: Asset[] } -const release = (await releaseRes.json()) as Release -const assets = release.assets ?? [] -const assetByName = new Map(assets.map((asset) => [asset.name, asset])) +const assets = ((await rel.json()) as Release).assets ?? [] +const amap = new Map(assets.map((item) => [item.name, item])) -const latestAsset = assetByName.get("latest.json") -if (!latestAsset) { - console.log("latest.json not found, skipping tauri finalization") - process.exit(0) +type Item = { + url: string } -const latestRes = await fetch(latestAsset.url, { - headers: { - Authorization: `token ${token}`, - Accept: "application/octet-stream", - }, -}) - -if (!latestRes.ok) { - throw new Error(`Failed to fetch latest.json: ${latestRes.status} ${latestRes.statusText}`) +type Yml = { + version: string + files: Item[] } -const latestText = new TextDecoder().decode(await latestRes.arrayBuffer()) -const latest = JSON.parse(latestText) -const base = { ...latest } -delete base.platforms +function parse(text: string): Yml { + const lines = text.split("\n") + let version = "" + const files: Item[] = [] + let url = "" -const fetchSignature = async (asset: Asset) => { - const res = await fetch(asset.url, { - headers: { - Authorization: `token ${token}`, - Accept: "application/octet-stream", - }, - }) + const flush = () => { + if (!url) return + files.push({ url }) + url = "" + } - if (!res.ok) { - throw new Error(`Failed to fetch signature: ${res.status} ${res.statusText}`) + for (const line of lines) { + const trim = line.trim() + if (line.startsWith("version:")) { + version = line.slice("version:".length).trim() + continue + } + if (trim.startsWith("- url:")) { + flush() + url = trim.slice("- url:".length).trim() + continue + } + const indented = line.startsWith(" ") || line.startsWith("\t") + if (!indented) flush() } + flush() - return Buffer.from(await res.arrayBuffer()).toString() + return { version, files } } -const entries: Record = {} -const add = (key: string, asset: Asset, signature: string) => { - if (entries[key]) return - entries[key] = { - url: `https://github.com/${repo}/releases/download/v${version}/${asset.name}`, - signature, +async function read(sub: string, file: string) { + const item = Bun.file(path.join(root, sub, file)) + if (!(await item.exists())) return undefined + return parse(await item.text()) +} + +function pick(list: Item[], exts: string[]) { + for (const ext of exts) { + const found = list.find((item) => item.url.split("?")[0]?.toLowerCase().endsWith(ext)) + if (found) return found.url } } -const targets = [ - { key: "linux-x86_64-deb", asset: "opencode-desktop-linux-amd64.deb" }, - { key: "linux-x86_64-rpm", asset: "opencode-desktop-linux-x86_64.rpm" }, - { key: "linux-aarch64-deb", asset: "opencode-desktop-linux-arm64.deb" }, - { key: "linux-aarch64-rpm", asset: "opencode-desktop-linux-aarch64.rpm" }, - { key: "windows-aarch64-nsis", asset: "opencode-desktop-windows-arm64.exe" }, - { key: "windows-x86_64-nsis", asset: "opencode-desktop-windows-x64.exe" }, - { key: "darwin-x86_64-app", asset: "opencode-desktop-darwin-x64.app.tar.gz" }, - { - key: "darwin-aarch64-app", - asset: "opencode-desktop-darwin-aarch64.app.tar.gz", - }, -] +function link(raw: string) { + if (raw.startsWith("https://") || raw.startsWith("http://")) return raw + return `https://github.com/${repo}/releases/download/v${version}/${raw}` +} -for (const target of targets) { - const asset = assetByName.get(target.asset) - if (!asset) continue +async function sign(url: string, key: string) { + const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? key) + const asset = amap.get(name) + const res = await fetch(asset?.url ?? url, { + headers: { + Authorization: `token ${token}`, + ...(asset ? { Accept: "application/octet-stream" } : {}), + }, + }) + if (!res.ok) { + throw new Error(`Failed to fetch file ${name}: ${res.status} ${res.statusText} (${asset?.url ?? url})`) + } - const sig = assetByName.get(`${target.asset}.sig`) - if (!sig) continue + const tmp = process.env.RUNNER_TEMP ?? "/tmp" + const file = path.join(tmp, name) + await Bun.write(file, await res.arrayBuffer()) + await $`bunx @tauri-apps/cli signer sign ${file}` + const sigFile = Bun.file(`${file}.sig`) + if (!(await sigFile.exists())) throw new Error(`Signature file not found for ${name}`) + return (await sigFile.text()).trim() +} - const signature = await fetchSignature(sig) - add(target.key, asset, signature) +const add = async (data: Record, key: string, raw: string | undefined) => { + if (!raw) return + if (data[key]) return + const url = link(raw) + data[key] = { url, signature: await sign(url, key) } } -const alias = (key: string, source: string) => { - if (entries[key]) return - const entry = entries[source] - if (!entry) return - entries[key] = entry +const alias = (data: Record, key: string, src: string) => { + if (data[key]) return + if (!data[src]) return + data[key] = data[src] } -alias("linux-x86_64", "linux-x86_64-deb") -alias("linux-aarch64", "linux-aarch64-deb") -alias("windows-aarch64", "windows-aarch64-nsis") -alias("windows-x86_64", "windows-x86_64-nsis") -alias("darwin-x86_64", "darwin-x86_64-app") -alias("darwin-aarch64", "darwin-aarch64-app") +const winx = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml") +const wina = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml") +const macx = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml") +const maca = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml") +const linx = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml") +const lina = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml") + +const yver = winx?.version ?? wina?.version ?? macx?.version ?? maca?.version ?? linx?.version ?? lina?.version +if (yver && yver !== version) throw new Error(`latest.yml version mismatch: expected ${version}, got ${yver}`) + +const out: Record = {} + +const winxexe = pick(winx?.files ?? [], [".exe"]) +const winaexe = pick(wina?.files ?? [], [".exe"]) + +const macxTarGz = "opencode-desktop-mac-x64.app.tar.gz" +const macaTarGz = "opencode-desktop-mac-arm64.app.tar.gz" + +const linxDeb = pick(linx?.files ?? [], [".deb"]) +const linxRpm = pick(linx?.files ?? [], [".rpm"]) +const linxAppImage = pick(linx?.files ?? [], [".appimage"]) +const linaDeb = pick(lina?.files ?? [], [".deb"]) +const linaRpm = pick(lina?.files ?? [], [".rpm"]) +const linaAppImage = pick(lina?.files ?? [], [".appimage"]) + +await add(out, "windows-x86_64-nsis", winxexe) +await add(out, "windows-aarch64-nsis", winaexe) +await add(out, "darwin-x86_64-app", macxTarGz) +await add(out, "darwin-aarch64-app", macaTarGz) + +await add(out, "linux-x86_64-deb", linxDeb) +await add(out, "linux-x86_64-rpm", linxRpm) +await add(out, "linux-x86_64-appimage", linxAppImage) +await add(out, "linux-aarch64-deb", linaDeb) +await add(out, "linux-aarch64-rpm", linaRpm) +await add(out, "linux-aarch64-appimage", linaAppImage) + +alias(out, "windows-x86_64", "windows-x86_64-nsis") +alias(out, "windows-aarch64", "windows-aarch64-nsis") +alias(out, "darwin-x86_64", "darwin-x86_64-app") +alias(out, "darwin-aarch64", "darwin-aarch64-app") +alias(out, "linux-x86_64", "linux-x86_64-deb") +alias(out, "linux-aarch64", "linux-aarch64-deb") const platforms = Object.fromEntries( - Object.keys(entries) + Object.keys(out) .sort() - .map((key) => [key, entries[key]]), + .map((key) => [key, out[key]]), ) -const output = { - ...base, + +if (!Object.keys(platforms).length) throw new Error("No updater files found in latest.yml artifacts") + +const data = { + version, + notes: "", + pub_date: new Date().toISOString(), platforms, } -const dir = process.env.RUNNER_TEMP ?? "/tmp" -const file = `${dir}/latest.json` -await Bun.write(file, JSON.stringify(output, null, 2)) +const tmp = process.env.RUNNER_TEMP ?? "/tmp" +const file = path.join(tmp, "latest.json") +await Bun.write(file, JSON.stringify(data, null, 2)) -const tag = release.tag_name -if (!tag) throw new Error("Release tag not found") +const tag = `v${version}` if (dryRun) { console.log(`dry-run: wrote latest.json for ${tag} to ${file}`) From 0a7d02c87cea5092f34aafba846d136870ac27bc Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 3 May 2026 19:18:26 +0530 Subject: [PATCH 117/178] feat: group changelog bugfixes (#25597) --- .opencode/command/changelog.md | 5 ++++- script/raw-changelog.ts | 40 +++++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md index 4cd30a704a..b28d963d00 100644 --- a/.opencode/command/changelog.md +++ b/.opencode/command/changelog.md @@ -18,9 +18,12 @@ Do not use `git log` or author metadata when deciding attribution. Rules: -- Write the final file with sections in this order: +- Write the final file with release sections in this order: `## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions` - Only include sections that have at least one notable entry +- Within each release section, keep bug fixes grouped under `### Bugfixes` +- Keep other notable entries under `### Improvements` when a section has bug fixes too +- Omit empty subsections - Keep one bullet per commit you keep - Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing - Start each bullet with a capital letter diff --git a/script/raw-changelog.ts b/script/raw-changelog.ts index 735b078be1..c571de322a 100644 --- a/script/raw-changelog.ts +++ b/script/raw-changelog.ts @@ -82,6 +82,11 @@ function section(areas: Set) { return "Core" } +function type(message: string) { + if (message.match(/fix/i)) return "Bugfixes" + return "Improvements" +} + function reverted(commits: Commit[]) { const seen = new Map() @@ -193,13 +198,20 @@ async function thanks(from: string, to: string, reuse: boolean) { } function format(from: string, to: string, list: Commit[], thanks: string[]) { - const grouped = new Map() - for (const title of order) grouped.set(title, []) + const grouped = new Map>() + for (const title of order) { + grouped.set( + title, + new Map([ + ["Improvements", []], + ["Bugfixes", []], + ]), + ) + } for (const commit of list) { - const title = section(commit.areas) const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : "" - grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`) + grouped.get(section(commit.areas))!.get(type(commit.message))!.push(`- \`${commit.hash}\` ${commit.message}${attr}`) } const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""] @@ -209,11 +221,23 @@ function format(from: string, to: string, list: Commit[], thanks: string[]) { } for (const title of order) { - const entries = grouped.get(title) - if (!entries || entries.length === 0) continue + const groups = grouped.get(title) + if (!groups || [...groups.values()].every((entries) => entries.length === 0)) continue lines.push(`## ${title}`) - lines.push(...entries) - lines.push("") + const improvements = groups.get("Improvements")! + const bugfixes = groups.get("Bugfixes")! + if (bugfixes.length === 0) { + lines.push(...improvements) + lines.push("") + continue + } + + for (const [subtitle, entries] of groups) { + if (entries.length === 0) continue + lines.push(`### ${subtitle}`) + lines.push(...entries) + lines.push("") + } } if (thanks.length > 0) { From 8694c5b68fc57e7e1bb8129b72b08e128dce9f17 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Sun, 3 May 2026 19:28:31 +0530 Subject: [PATCH 118/178] fix(auth): respect server username in clients (#25596) --- packages/opencode/src/cli/cmd/acp.ts | 10 +--- packages/opencode/src/cli/cmd/run.ts | 9 +-- packages/opencode/src/cli/cmd/tui/attach.ts | 13 ++-- packages/opencode/src/cli/cmd/tui/worker.ts | 11 +--- packages/opencode/src/plugin/index.ts | 7 +-- packages/opencode/src/server/auth.ts | 48 +++++++++++++++ .../httpapi/middleware/authorization.ts | 49 ++++----------- .../server/routes/instance/httpapi/server.ts | 9 +-- packages/opencode/test/server/auth.test.ts | 59 +++++++++++++++++++ .../test/server/httpapi-authorization.test.ts | 13 ++-- .../opencode/test/server/httpapi-ui.test.ts | 8 +-- 11 files changed, 148 insertions(+), 88 deletions(-) create mode 100644 packages/opencode/src/server/auth.ts create mode 100644 packages/opencode/test/server/auth.test.ts diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index 1bf52a0c8f..e24262307c 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -4,9 +4,9 @@ import { effectCmd } from "../effect-cmd" import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { ACP } from "@/acp/agent" import { Server } from "@/server/server" +import { ServerAuth } from "@/server/auth" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { withNetworkOptions, resolveNetworkOptions } from "../network" -import { Flag } from "@opencode-ai/core/flag/flag" const log = Log.create({ service: "acp-command" }) @@ -27,13 +27,7 @@ export const AcpCommand = effectCmd({ const sdk = createOpencodeClient({ baseUrl: `http://${server.hostname}:${server.port}`, - headers: Flag.OPENCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from( - `${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`, - ).toString("base64")}`, - } - : undefined, + headers: ServerAuth.headers(), }) const input = new WritableStream({ diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 75f68e8ea0..2ec0b179b8 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -5,6 +5,7 @@ import { Effect } from "effect" import { UI } from "../ui" import { effectCmd } from "../effect-cmd" import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "@/server/auth" import { EOL } from "os" import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" @@ -656,13 +657,7 @@ export const RunCommand = effectCmd({ } if (args.attach) { - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode" - const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` - return { Authorization: auth } - })() + const headers = ServerAuth.headers({ password: args.password }) const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) return await execute(sdk) } diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index cb6b95a56c..5de937fdcc 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -5,6 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { errorMessage } from "@/util/error" import { validateSession } from "./validate-session" +import { ServerAuth } from "@/server/auth" export const AttachCommand = cmd({ command: "attach ", @@ -38,6 +39,11 @@ export const AttachCommand = cmd({ alias: ["p"], type: "string", describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", + }) + .option("username", { + alias: ["u"], + type: "string", + describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", }), handler: async (args) => { const unguard = win32InstallCtrlCGuard() @@ -60,12 +66,7 @@ export const AttachCommand = cmd({ return args.dir } })() - const headers = (() => { - const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` - return { Authorization: auth } - })() + const headers = ServerAuth.headers({ password: args.password, username: args.username }) const config = await TuiConfig.get() try { diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 775f321bb5..90ff2b4d4f 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -7,7 +7,7 @@ import { Rpc } from "@/util/rpc" import { upgrade } from "@/cli/upgrade" import { Config } from "@/config/config" import { GlobalBus } from "@/bus/global" -import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "@/server/auth" import { writeHeapSnapshot } from "node:v8" import { Heap } from "@/cli/heap" import { AppRuntime } from "@/effect/app-runtime" @@ -50,7 +50,7 @@ let server: Awaited> | undefined export const rpc = { async fetch(input: { url: string; method: string; headers: Record; body?: string }) { const headers = { ...input.headers } - const auth = getAuthorizationHeader() + const auth = ServerAuth.header() if (auth && !headers["authorization"] && !headers["Authorization"]) { headers["Authorization"] = auth } @@ -102,10 +102,3 @@ export const rpc = { } Rpc.listen(rpc) - -function getAuthorizationHeader(): string | undefined { - const password = Flag.OPENCODE_SERVER_PASSWORD - if (!password) return undefined - const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" - return `Basic ${btoa(`${username}:${password}`)}` -} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 95af410ff9..7a7f260df8 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -10,6 +10,7 @@ import { Bus } from "../bus" import * as Log from "@opencode-ai/core/util/log" import { createOpencodeClient } from "@opencode-ai/sdk" import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "@/server/auth" import { CodexAuthPlugin } from "./codex" import { Session } from "@/session/session" import { NamedError } from "@opencode-ai/core/util/error" @@ -124,11 +125,7 @@ export const layer = Layer.effect( const client = createOpencodeClient({ baseUrl: "http://localhost:4096", directory: ctx.directory, - headers: Flag.OPENCODE_SERVER_PASSWORD - ? { - Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, - } - : undefined, + headers: ServerAuth.headers(), fetch: async (...args) => Server.Default().app.fetch(...args), }) const cfg = yield* config.get() diff --git a/packages/opencode/src/server/auth.ts b/packages/opencode/src/server/auth.ts new file mode 100644 index 0000000000..9630ddbe20 --- /dev/null +++ b/packages/opencode/src/server/auth.ts @@ -0,0 +1,48 @@ +export * as ServerAuth from "./auth" + +import { ConfigService } from "@/effect/config-service" +import { Flag } from "@opencode-ai/core/flag/flag" +import { Config as EffectConfig, Context, Option, Redacted } from "effect" + +export type Credentials = { + password?: string + username?: string +} + +export type DecodedCredentials = { + readonly username: string + readonly password: Redacted.Redacted +} + +export class Config extends ConfigService.Service()("@opencode/ServerAuthConfig", { + password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option), + username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")), +}) {} + +export type Info = Context.Service.Shape + +export function required(config: Info) { + return Option.isSome(config.password) && config.password.value !== "" +} + +export function authorized(credentials: DecodedCredentials, config: Info) { + return ( + Option.isSome(config.password) && + credentials.username === config.username && + Redacted.value(credentials.password) === config.password.value + ) +} + +export function header(credentials?: Credentials) { + const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD + if (!password) return undefined + + const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +} + +export function headers(credentials?: Credentials) { + const authorization = header(credentials) + if (!authorization) return undefined + return { Authorization: authorization } +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 4edd06479b..bd9552edcd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,5 +1,5 @@ -import { ConfigService } from "@/effect/config-service" -import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect" +import { ServerAuth } from "@/server/auth" +import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" @@ -18,41 +18,18 @@ export class Authorization extends HttpApiMiddleware.Service()( }, ) {} -export class ServerAuthConfig extends ConfigService.Service()( - "@opencode/ExperimentalHttpApiServerAuthConfig", - { - password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option), - username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")), - }, -) {} - function validateCredential( effect: Effect.Effect, - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, + credential: ServerAuth.DecodedCredentials, + config: ServerAuth.Info, ) { return Effect.gen(function* () { - if (!isAuthRequired(config)) return yield* effect - if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({}) + if (!ServerAuth.required(config)) return yield* effect + if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({}) return yield* effect }) } -function isAuthRequired(config: Context.Service.Shape) { - return Option.isSome(config.password) && config.password.value !== "" -} - -function isCredentialAuthorized( - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, -) { - return ( - Option.isSome(config.password) && - credential.username === config.username && - Redacted.value(credential.password) === config.password.value - ) -} - function decodeCredential(input: string) { const emptyCredential = { username: "", @@ -78,11 +55,11 @@ function decodeCredential(input: string) { function validateRawCredential( effect: Effect.Effect, - credential: { readonly username: string; readonly password: Redacted.Redacted }, - config: Context.Service.Shape, + credential: ServerAuth.DecodedCredentials, + config: ServerAuth.Info, ) { - if (!isAuthRequired(config)) return effect - if (!isCredentialAuthorized(credential, config)) + if (!ServerAuth.required(config)) return effect + if (!ServerAuth.authorized(credential, config)) return Effect.succeed( HttpServerResponse.empty({ status: UNAUTHORIZED, @@ -94,8 +71,8 @@ function validateRawCredential( export const authorizationRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { - const config = yield* ServerAuthConfig - if (!isAuthRequired(config)) return (effect) => effect + const config = yield* ServerAuth.Config + if (!ServerAuth.required(config)) return (effect) => effect return (effect) => Effect.gen(function* () { @@ -122,7 +99,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( export const authorizationLayer = Layer.effect( Authorization, Effect.gen(function* () { - const config = yield* ServerAuthConfig + const config = yield* ServerAuth.Config return Authorization.of({ basic: (effect, { credential }) => validateCredential(effect, credential, config), authToken: (effect, { credential }) => diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 650efe2b0d..2944ced695 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -46,8 +46,9 @@ import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/shared/ui" +import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" -import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" +import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { EventApi, eventHandlers } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -97,7 +98,7 @@ const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([cont const instanceRouterLayer = authorizationRouterMiddleware .combine(instanceRouterMiddleware) .combine(workspaceRouterMiddleware) - .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer)) + .layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer)) const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe( Layer.provide(eventHandlers), Layer.provide(instanceRouterLayer), @@ -125,7 +126,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe( const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ - authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)), + authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)), workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, ]), @@ -137,7 +138,7 @@ const uiRoute = HttpRouter.use((router) => const client = yield* HttpClient.HttpClient yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), -).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) +).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)))) export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( diff --git a/packages/opencode/test/server/auth.test.ts b/packages/opencode/test/server/auth.test.ts new file mode 100644 index 0000000000..1278e8c72e --- /dev/null +++ b/packages/opencode/test/server/auth.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Option, Redacted } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ServerAuth } from "../../src/server/auth" + +const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +afterEach(() => { + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME +}) + +describe("ServerAuth", () => { + test("does not emit auth headers without a password", () => { + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.header()).toBeUndefined() + expect(ServerAuth.headers()).toBeUndefined() + }) + + test("defaults to the opencode username", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = undefined + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`, + }) + }) + + test("uses the configured username", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers()).toEqual({ + Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`, + }) + }) + + test("prefers explicit credentials", () => { + Flag.OPENCODE_SERVER_PASSWORD = "secret" + Flag.OPENCODE_SERVER_USERNAME = "alice" + + expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({ + Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`, + }) + }) + + test("validates decoded credentials against effect config", () => { + const config = { password: Option.some("secret"), username: "alice" } + + expect(ServerAuth.required(config)).toBe(true) + expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true) + expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false) + }) +}) diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index c3bab23ac7..d780b18f24 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -3,11 +3,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" -import { - Authorization, - ServerAuthConfig, - authorizationLayer, -} from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { ServerAuth } from "../../src/server/auth" +import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { testEffect } from "../lib/effect" const Api = HttpApi.make("test-authorization").add( @@ -27,9 +24,9 @@ const apiLayer = HttpRouter.serve( { disableListenLog: true, disableLogger: true }, ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) -const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" }) -const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" }) -const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" }) +const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" }) +const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" }) +const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" }) const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer))) const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer))) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 1de8a489cd..8b7a6a1ac3 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -12,10 +12,8 @@ import { HttpServerResponse, } from "effect/unstable/http" import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { - ServerAuthConfig, - authorizationRouterMiddleware, -} from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { ServerAuth } from "../../src/server/auth" +import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { serveUIEffect } from "../../src/server/shared/ui" import { Server } from "../../src/server/server" @@ -81,7 +79,7 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), ).pipe( - Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))), + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), Layer.provide([ AppFileSystem.defaultLayer, input?.client ?? httpClient(new Response("ui")), From 13ac849db5c378ed04d02d644006f01e70db31b6 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 11:21:34 -0400 Subject: [PATCH 119/178] refactor(config+core): drop ConfigPaths.readFile, add AppFileSystem.readFileStringSafe, flatten TuiConfig.loadState (#25602) --- packages/core/src/filesystem.ts | 8 ++ .../core/test/filesystem/filesystem.test.ts | 28 +++++ .../opencode/src/cli/cmd/tui/config/tui.ts | 119 ++++++++++-------- packages/opencode/src/config/config.ts | 10 +- packages/opencode/src/config/paths.ts | 10 -- packages/opencode/test/config/tui.test.ts | 40 ++++++ 6 files changed, 146 insertions(+), 69 deletions(-) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 44346be8f9..8a1cc3a08f 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -24,6 +24,7 @@ export namespace AppFileSystem { readonly isDir: (path: string) => Effect.Effect readonly isFile: (path: string) => Effect.Effect readonly existsSafe: (path: string) => Effect.Effect + readonly readFileStringSafe: (path: string) => Effect.Effect readonly readJson: (path: string) => Effect.Effect readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect readonly ensureDir: (path: string) => Effect.Effect @@ -47,6 +48,12 @@ export namespace AppFileSystem { return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false)) }) + const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) { + return yield* fs + .readFileString(path) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) + }) + const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) { const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void)) return info?.type === "Directory" @@ -163,6 +170,7 @@ export namespace AppFileSystem { return Service.of({ ...fs, existsSafe, + readFileStringSafe, isDir, isFile, readDirectoryEntries, diff --git a/packages/core/test/filesystem/filesystem.test.ts b/packages/core/test/filesystem/filesystem.test.ts index b77f4e356f..1d9405333d 100644 --- a/packages/core/test/filesystem/filesystem.test.ts +++ b/packages/core/test/filesystem/filesystem.test.ts @@ -65,6 +65,34 @@ describe("AppFileSystem", () => { ) }) + describe("readFileStringSafe", () => { + it( + "returns file contents when file exists", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + const file = path.join(tmp, "exists.txt") + yield* filesys.writeFileString(file, "hello") + + const result = yield* fs.readFileStringSafe(file) + expect(result).toBe("hello") + }), + ) + + it( + "returns undefined for missing file (NotFound)", + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const filesys = yield* FileSystem.FileSystem + const tmp = yield* filesys.makeTempDirectoryScoped() + + const result = yield* fs.readFileStringSafe(path.join(tmp, "does-not-exist.txt")) + expect(result).toBeUndefined() + }), + ) + }) + describe("readJson / writeJson", () => { it( "round-trips JSON data", diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index fbedcccc1b..e9824a09d6 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -68,29 +68,73 @@ function normalize(raw: Record) { } } -async function resolvePlugins(config: Info, configFilepath: string) { - if (!config.plugin) return config - for (let i = 0; i < config.plugin.length; i++) { - config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath) - } - return config -} +const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { + const afs = yield* AppFileSystem.Service + + const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect => + Effect.gen(function* () { + const plugins = config.plugin + if (!plugins) return config + for (let i = 0; i < plugins.length; i++) { + plugins[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugins[i], configFilepath)) + } + return config + }) -async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) { - const data = await loadFile(file) - acc.result = mergeDeep(acc.result, data) - if (!data.plugin?.length) return - - const scope = pluginScope(file, ctx) - const plugins = ConfigPlugin.deduplicatePluginOrigins([ - ...(acc.result.plugin_origins ?? []), - ...data.plugin.map((spec) => ({ spec, scope, source: file })), - ]) - acc.result.plugin = plugins.map((item) => item.spec) - acc.result.plugin_origins = plugins -} + const load = (text: string, configFilepath: string): Effect.Effect => + Effect.gen(function* () { + const expanded = yield* Effect.promise(() => + ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), + ) + const data = ConfigParse.jsonc(expanded, configFilepath) + if (!isRecord(data)) return {} as Info + // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json + // (mirroring the old opencode.json shape) still get their settings applied. + const validated = ConfigParse.schema(Info, normalize(data), configFilepath) + return yield* resolvePlugins(validated, configFilepath) + }).pipe( + // catchCause (not tapErrorCause + orElseSucceed) because ConfigParse.jsonc/.schema + // can sync-throw — those become defects, which orElseSucceed wouldn't catch. + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("invalid tui config", { path: configFilepath, cause }) + return {} as Info + }), + ), + ) + + const loadFile = (filepath: string): Effect.Effect => + Effect.gen(function* () { + // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. + // Matches how parse/schema/plugin failures in load() are handled — every + // broken-config path degrades gracefully rather than crashing TUI startup. + const text = yield* afs.readFileStringSafe(filepath).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + log.warn("failed to read tui config", { path: filepath, cause }) + return undefined + }), + ), + ) + if (!text) return {} as Info + return yield* load(text, filepath) + }) + + const mergeFile = (acc: Acc, file: string) => + Effect.gen(function* () { + const data = yield* loadFile(file) + acc.result = mergeDeep(acc.result, data) + if (!data.plugin?.length) return + + const scope = pluginScope(file, ctx) + const plugins = ConfigPlugin.deduplicatePluginOrigins([ + ...(acc.result.plugin_origins ?? []), + ...data.plugin.map((spec) => ({ spec, scope, source: file })), + ]) + acc.result.plugin = plugins.map((item) => item.spec) + acc.result.plugin_origins = plugins + }) -const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { // Every config dir we may read from: global config dir, any `.opencode` // folders between cwd and home, and OPENCODE_CONFIG_DIR. const directories = yield* ConfigPaths.directories(ctx.directory) @@ -104,19 +148,19 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: // 1. Global tui config (lowest precedence). for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } // 2. Explicit OPENCODE_TUI_CONFIG override, if set. if (Flag.OPENCODE_TUI_CONFIG) { const configFile = Flag.OPENCODE_TUI_CONFIG - yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, configFile) log.debug("loaded custom tui config", { path: configFile }) } // 3. Project tui files, applied root-first so the closest file wins. for (const file of projectFiles) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } // 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while @@ -127,7 +171,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: for (const dir of dirs) { if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { - yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie) + yield* mergeFile(acc, file) } } @@ -193,28 +237,3 @@ export async function get() { return runPromise((svc) => svc.get()) } -async function loadFile(filepath: string): Promise { - const text = await ConfigPaths.readFile(filepath) - if (!text) return {} - return load(text, filepath).catch((error) => { - log.warn("failed to load tui config", { path: filepath, error }) - return {} - }) -} - -async function load(text: string, configFilepath: string): Promise { - return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }) - .then((expanded) => ConfigParse.jsonc(expanded, configFilepath)) - .then((data) => { - if (!isRecord(data)) return {} - - // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json - // (mirroring the old opencode.json shape) still get their settings applied. - return ConfigParse.schema(Info, normalize(data), configFilepath) - }) - .then((data) => resolvePlugins(data, configFilepath)) - .catch((error) => { - log.warn("invalid tui config", { path: configFilepath, error }) - return {} - }) -} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c6557360bb..3a933f81e9 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -355,15 +355,7 @@ export const layer = Layer.effect( const env = yield* Env.Service const npmSvc = yield* Npm.Service - const readConfigFile = Effect.fnUntraced(function* (filepath: string) { - return yield* fs.readFileString(filepath).pipe( - Effect.catchIf( - (e) => e.reason._tag === "NotFound", - () => Effect.succeed(undefined), - ), - Effect.orDie, - ) - }) + const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) const loadConfig = Effect.fnUntraced(function* ( text: string, diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 90f49ee799..82fca570f4 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -1,11 +1,9 @@ export * as ConfigPaths from "./paths" import path from "path" -import { Filesystem } from "@/util/filesystem" import { Flag } from "@opencode-ai/core/flag/flag" import { Global } from "@opencode-ai/core/global" import { unique } from "remeda" -import { JsonError } from "./error" import * as Effect from "effect/Effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -45,11 +43,3 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc export function fileInDirectory(dir: string, name: string) { return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] } - -/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */ -export async function readFile(filepath: string) { - return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => { - if (err.code === "ENOENT") return - throw new JsonError({ path: filepath }, { cause: err }) - }) -} diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index a3f2a1b5fb..5053a7e1f7 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -627,3 +627,43 @@ test("merges plugin_enabled flags across config layers", async () => { "local.plugin": true, }) }) + +test("silently skips malformed tui.json — load failures degrade to {}", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "tui.json"), '{ "theme": "broken",') + await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) + }, + }) + + const config = await getTuiConfig(tmp.path) + // Project tui.json is malformed → silently skipped (logs a warning) + // .opencode/tui.json (lower precedence in this path) still loads + expect(config.theme).toBe("fallback") +}) + +test("silently skips non-ENOENT read failures (e.g. tui.json is a directory) — fallback layer still loads", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // tui.json exists as a DIRECTORY rather than a file → readFileString fails + // with EISDIR (PlatformError reason ≠ NotFound). The fix in this PR routes + // that through catchCause → log + skip, so a fallback layer should still load. + await fs.mkdir(path.join(dir, "tui.json"), { recursive: true }) + await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" })) + }, + }) + + const config = await getTuiConfig(tmp.path) + // Did NOT crash; .opencode/tui.json (lower precedence) still loads. + expect(config.theme).toBe("fallback") +}) + +test("missing tui.json — silently treated as empty (ENOENT path)", async () => { + await using tmp = await tmpdir({}) + + // No tui.json anywhere. Should not throw. + const config = await getTuiConfig(tmp.path) + expect(config).toBeDefined() + // No theme set anywhere. + expect(config.theme).toBeUndefined() +}) From 57d5c095d83d934120d2ac88afdf208b4523f1d2 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 15:22:38 +0000 Subject: [PATCH 120/178] chore: generate --- packages/opencode/src/cli/cmd/tui/config/tui.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index e9824a09d6..890f736228 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -236,4 +236,3 @@ export async function waitForDependencies() { export async function get() { return runPromise((svc) => svc.get()) } - From df7dd06a0fffa96bb495136cbe6f76680ed1a911 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 11:42:05 -0400 Subject: [PATCH 121/178] =?UTF-8?q?refactor(cli/github+run):=20Stage=204?= =?UTF-8?q?=20=E2=80=94=20drop=20AppRuntime.runPromise=20bridges=20(#25539?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/github.ts | 50 +++++++++++++------------ packages/opencode/src/cli/cmd/run.ts | 4 +- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index a4a209ea39..ea5b35ef78 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -29,7 +29,6 @@ import { Provider } from "@/provider/provider" import { Bus } from "../../bus" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "@/session/prompt" -import { AppRuntime } from "@/effect/app-runtime" import { Git } from "@/git" import { setTimeout as sleep } from "node:timers/promises" import { Process } from "@/util/process" @@ -206,6 +205,8 @@ export const GithubInstallCommand = effectCmd({ const maybeCtx = yield* InstanceRef if (!maybeCtx) return yield* Effect.die("InstanceRef not provided") const ctx = maybeCtx + const modelsDev = yield* ModelsDev.Service + const gitSvc = yield* Git.Service yield* Effect.promise(async () => { { UI.empty() @@ -213,7 +214,7 @@ export const GithubInstallCommand = effectCmd({ const app = await getAppInfo() await installGitHubApp() - const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => { + const providers = await Effect.runPromise(modelsDev.get()).then((p) => { // TODO: add guide for copilot, for now just hide it delete p["github-copilot"] return p @@ -261,9 +262,9 @@ export const GithubInstallCommand = effectCmd({ } // Get repo info - const info = await AppRuntime.runPromise( - Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })), - ).then((x) => x.text().trim()) + const info = await Effect.runPromise(gitSvc.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })).then( + (x) => x.text().trim(), + ) const parsed = parseGitHubRemote(info) if (!parsed) { prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) @@ -440,6 +441,10 @@ export const GithubRunCommand = effectCmd({ handler: Effect.fn("Cli.github.run")(function* (args) { const ctx = yield* InstanceRef if (!ctx) return yield* Effect.die("InstanceRef not provided") + const gitSvc = yield* Git.Service + const sessionSvc = yield* Session.Service + const sessionShare = yield* SessionShare.Service + const sessionPrompt = yield* SessionPrompt.Service yield* Effect.promise(async () => { const isMock = args.token || args.event @@ -503,21 +508,20 @@ export const GithubRunCommand = effectCmd({ : "issue" : undefined const gitText = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) + const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result.text().trim() } const gitRun = async (args: string[]) => { - const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) + const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) if (result.exitCode !== 0) { throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) } return result } - const gitStatus = (args: string[]) => - AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) + const gitStatus = (args: string[]) => Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree })) const commitChanges = async (summary: string, actor?: string) => { const args = ["commit", "-m", summary] if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) @@ -554,24 +558,22 @@ export const GithubRunCommand = effectCmd({ // Setup opencode session const repoData = await fetchRepo() - session = await AppRuntime.runPromise( - Session.Service.use((svc) => - svc.create({ - permission: [ - { - permission: "question", - action: "deny", - pattern: "*", - }, - ], - }), - ), + session = await Effect.runPromise( + sessionSvc.create({ + permission: [ + { + permission: "question", + action: "deny", + pattern: "*", + }, + ], + }), ) subscribeSessionEvents() shareId = await (async () => { if (share === false) return if (!share && repoData.data.private) return - await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id))) + await Effect.runPromise(sessionShare.share(session.id)) return session.id.slice(-8) })() console.log("opencode session", session.id) @@ -944,9 +946,9 @@ export const GithubRunCommand = effectCmd({ async function chat(message: string, files: PromptFiles = []) { console.log("Sending message to opencode...") - return AppRuntime.runPromise( + return Effect.runPromise( Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service + const prompt = sessionPrompt const result = yield* prompt.prompt({ sessionID: session.id, messageID: MessageID.ascending(), diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 2ec0b179b8..c20833d4be 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -27,7 +27,6 @@ import { ShellTool } from "../../tool/shell" import { ShellID } from "../../tool/shell/id" import { TodoWriteTool } from "../../tool/todo" import { Locale } from "@/util/locale" -import { AppRuntime } from "@/effect/app-runtime" type ToolProps = { input: Tool.InferParameters @@ -300,6 +299,7 @@ export const RunCommand = effectCmd({ default: false, }), handler: Effect.fn("Cli.run")(function* (args) { + const agentSvc = yield* Agent.Service yield* Effect.promise(async () => { let message = [...args.message, ...(args["--"] || [])] .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) @@ -603,7 +603,7 @@ export const RunCommand = effectCmd({ return name } - const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name))) + const entry = await Effect.runPromise(agentSvc.get(name)) if (!entry) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", From 40dc2fa3c1d6217d0f4fd21d813160e41f438a55 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 11:42:57 -0400 Subject: [PATCH 122/178] =?UTF-8?q?refactor(cli/providers):=20flatten=20?= =?UTF-8?q?=E2=80=94=20Effect-native=20handlers=20end-to-end=20(#25537)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/providers.ts | 391 ++++++++++----------- 1 file changed, 189 insertions(+), 202 deletions(-) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 081bcece00..c8d897bea8 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -6,8 +6,6 @@ import * as prompts from "@clack/prompts" import { UI } from "../ui" import { ModelsDev } from "@/provider/models" -const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())) -const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true))) import { map, pipe, sortBy, values } from "remeda" import path from "path" import os from "os" @@ -241,46 +239,45 @@ export const ProvidersListCommand = effectCmd({ handler: Effect.fn("Cli.providers.list")(function* (_args) { const authSvc = yield* Auth.Service const modelsDev = yield* ModelsDev.Service - yield* Effect.promise(async () => { - UI.empty() - const authPath = path.join(Global.Path.data, "auth.json") - const homedir = os.homedir() - const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath - prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = Object.entries(await Effect.runPromise(authSvc.all())) - const database = await Effect.runPromise(modelsDev.get()) - - for (const [providerID, result] of results) { - const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) - } - prompts.outro(`${results.length} credentials`) + UI.empty() + const authPath = path.join(Global.Path.data, "auth.json") + const homedir = os.homedir() + const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath + prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + const results = Object.entries(yield* Effect.orDie(authSvc.all())) + const database = yield* modelsDev.get() + + for (const [providerID, result] of results) { + const name = database[providerID]?.name || providerID + prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + } + + prompts.outro(`${results.length} credentials`) - const activeEnvVars: Array<{ provider: string; envVar: string }> = [] + const activeEnvVars: Array<{ provider: string; envVar: string }> = [] - for (const [providerID, provider] of Object.entries(database)) { - for (const envVar of provider.env) { - if (process.env[envVar]) { - activeEnvVars.push({ - provider: provider.name || providerID, - envVar, - }) - } + for (const [providerID, provider] of Object.entries(database)) { + for (const envVar of provider.env) { + if (process.env[envVar]) { + activeEnvVars.push({ + provider: provider.name || providerID, + envVar, + }) } } + } - if (activeEnvVars.length > 0) { - UI.empty() - prompts.intro("Environment") - - for (const { provider, envVar } of activeEnvVars) { - prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) - } + if (activeEnvVars.length > 0) { + UI.empty() + prompts.intro("Environment") - prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) + for (const { provider, envVar } of activeEnvVars) { + prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) } - }) + + prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) + } }), }) @@ -306,185 +303,174 @@ export const ProvidersLoginCommand = effectCmd({ handler: Effect.fn("Cli.providers.login")(function* (args) { const cfgSvc = yield* Config.Service const pluginSvc = yield* Plugin.Service - yield* Effect.promise(async () => { - UI.empty() - prompts.intro("Add credential") - if (args.url) { - const url = args.url.replace(/\/+$/, "") - const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as { - auth: { command: string[]; env: string } - } - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { - stdout: "pipe", - stderr: "inherit", - }) - if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)]) - if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") - return - } - await put(url, { - type: "wellknown", - key: wellknown.auth.env, - token: token.trim(), - }) - prompts.log.success("Logged into " + url) + const modelsDev = yield* ModelsDev.Service + const authSvc = yield* Auth.Service + + UI.empty() + prompts.intro("Add credential") + if (args.url) { + const url = args.url.replace(/\/+$/, "") + const wellknown = (yield* Effect.promise(() => + fetch(`${url}/.well-known/opencode`).then((x) => x.json()), + )) as { auth: { command: string[]; env: string } } + prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" }) + if (!proc.stdout) { + prompts.log.error("Failed") prompts.outro("Done") return } - await refreshModels().catch(() => {}) + const [exit, token] = yield* Effect.promise(() => Promise.all([proc.exited, text(proc.stdout!)])) + if (exit !== 0) { + prompts.log.error("Failed") + prompts.outro("Done") + return + } + yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() })) + prompts.log.success("Logged into " + url) + prompts.outro("Done") + return + } + yield* Effect.ignore(modelsDev.refresh(true)) - const config = await Effect.runPromise(cfgSvc.get()) + const config = yield* cfgSvc.get() - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined + const disabled = new Set(config.disabled_providers ?? []) + const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined - const providers = await getModels().then((x) => { - const filtered: Record = {} - for (const [key, value] of Object.entries(x)) { - if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) { - filtered[key] = value - } - } - return filtered - }) - const hooks = await Effect.runPromise(pluginSvc.list()) - - const priority: Record = { - opencode: 0, - openai: 1, - "github-copilot": 2, - google: 3, - anthropic: 4, - openrouter: 5, - vercel: 6, - } - const pluginProviders = resolvePluginProviders({ - hooks, - existingProviders: providers, - disabled, - enabled, - providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), - }) - const options = [ - ...pipe( - providers, - values(), - sortBy( - (x) => priority[x.id] ?? 99, - (x) => x.name ?? x.id, - ), - map((x) => ({ - label: x.name, - value: x.id, - hint: { - opencode: "recommended", - openai: "ChatGPT Plus/Pro or API key", - }[x.id], - })), + const allProviders = yield* modelsDev.get() + const providers: Record = {} + for (const [key, value] of Object.entries(allProviders)) { + if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value + } + const hooks = yield* pluginSvc.list() + + const priority: Record = { + opencode: 0, + openai: 1, + "github-copilot": 2, + google: 3, + anthropic: 4, + openrouter: 5, + vercel: 6, + } + const pluginProviders = resolvePluginProviders({ + hooks, + existingProviders: providers, + disabled, + enabled, + providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])), + }) + const options = [ + ...pipe( + providers, + values(), + sortBy( + (x) => priority[x.id] ?? 99, + (x) => x.name ?? x.id, ), - ...pluginProviders.map((x) => ({ + map((x) => ({ label: x.name, value: x.id, - hint: "plugin", + hint: { + opencode: "recommended", + openai: "ChatGPT Plus/Pro or API key", + }[x.id], })), - ] - - let provider: string - if (args.provider) { - const input = args.provider - const byID = options.find((x) => x.value === input) - const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) - const match = byID ?? byName - if (!match) { - prompts.log.error(`Unknown provider "${input}"`) - process.exit(1) - } - provider = match.value - } else { - const selected = await prompts.autocomplete({ + ), + ...pluginProviders.map((x) => ({ + label: x.name, + value: x.id, + hint: "plugin", + })), + ] + + let provider: string + if (args.provider) { + const input = args.provider + const byID = options.find((x) => x.value === input) + const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) + const match = byID ?? byName + if (!match) { + prompts.log.error(`Unknown provider "${input}"`) + process.exit(1) + } + provider = match.value + } else { + const selected = yield* Effect.promise(() => + prompts.autocomplete({ message: "Select provider", maxItems: 8, - options: [ - ...options, - { - value: "other", - label: "Other", - }, - ], - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - provider = selected as string - } + options: [...options, { value: "other", label: "Other" }], + }), + ) + if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError()) + provider = selected as string + } - const plugin = hooks.findLast((x) => x.auth?.provider === provider) - if (plugin && plugin.auth) { - const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method) - if (handled) return - } + const plugin = hooks.findLast((x) => x.auth?.provider === provider) + if (plugin && plugin.auth) { + const handled = yield* Effect.promise(() => handlePluginAuth({ auth: plugin.auth! }, provider, args.method)) + if (handled) return + } - if (provider === "other") { - const custom = await prompts.text({ + if (provider === "other") { + const custom = yield* Effect.promise(() => + prompts.text({ message: "Enter provider id", validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), - }) - if (prompts.isCancel(custom)) throw new UI.CancelledError() - provider = custom.replace(/^@ai-sdk\//, "") - - const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) - if (customPlugin && customPlugin.auth) { - const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method) - if (handled) return - } + }), + ) + if (prompts.isCancel(custom)) yield* Effect.die(new UI.CancelledError()) + provider = (custom as string).replace(/^@ai-sdk\//, "") - prompts.log.warn( - `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, + const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) + if (customPlugin && customPlugin.auth) { + const handled = yield* Effect.promise(() => + handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method), ) + if (handled) return } - if (provider === "amazon-bedrock") { - prompts.log.info( - "Amazon Bedrock authentication priority:\n" + - " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + - " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + - "Configure via opencode.json options (profile, region, endpoint) or\n" + - "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", - ) - } + prompts.log.warn( + `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, + ) + } - if (provider === "opencode") { - prompts.log.info("Create an api key at https://opencode.ai/auth") - } + if (provider === "amazon-bedrock") { + prompts.log.info( + "Amazon Bedrock authentication priority:\n" + + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + + "Configure via opencode.json options (profile, region, endpoint) or\n" + + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", + ) + } - if (provider === "vercel") { - prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") - } + if (provider === "opencode") { + prompts.log.info("Create an api key at https://opencode.ai/auth") + } - if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { - prompts.log.info( - "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", - ) - } + if (provider === "vercel") { + prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") + } - const key = await prompts.password({ + if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { + prompts.log.info( + "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", + ) + } + + const key = yield* Effect.promise(() => + prompts.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }) - if (prompts.isCancel(key)) throw new UI.CancelledError() - await put(provider, { - type: "api", - key, - }) + }), + ) + if (prompts.isCancel(key)) yield* Effect.die(new UI.CancelledError()) + yield* Effect.orDie(authSvc.set(provider, { type: "api", key: key as string })) - prompts.outro("Done") - }) + prompts.outro("Done") }), }) @@ -496,26 +482,27 @@ export const ProvidersLogoutCommand = effectCmd({ handler: Effect.fn("Cli.providers.logout")(function* (_args) { const authSvc = yield* Auth.Service const modelsDev = yield* ModelsDev.Service - yield* Effect.promise(async () => { - UI.empty() - const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all())) - prompts.intro("Remove credential") - if (credentials.length === 0) { - prompts.log.error("No credentials found") - return - } - const database = await Effect.runPromise(modelsDev.get()) - const selected = await prompts.select({ + + UI.empty() + const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) + prompts.intro("Remove credential") + if (credentials.length === 0) { + prompts.log.error("No credentials found") + return + } + const database = yield* modelsDev.get() + const selected = yield* Effect.promise(() => + prompts.select({ message: "Select provider", options: credentials.map(([key, value]) => ({ label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", value: key, })), - }) - if (prompts.isCancel(selected)) throw new UI.CancelledError() - const providerID = selected as string - await Effect.runPromise(authSvc.remove(providerID)) - prompts.outro("Logout successful") - }) + }), + ) + if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError()) + const providerID = selected as string + yield* Effect.orDie(authSvc.remove(providerID)) + prompts.outro("Logout successful") }), }) From c06af70ab027088a1729e9b8306d5a79804ce728 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 15:44:02 +0000 Subject: [PATCH 123/178] chore: generate --- packages/opencode/src/cli/cmd/providers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index c8d897bea8..44fa420153 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -310,9 +310,9 @@ export const ProvidersLoginCommand = effectCmd({ prompts.intro("Add credential") if (args.url) { const url = args.url.replace(/\/+$/, "") - const wellknown = (yield* Effect.promise(() => - fetch(`${url}/.well-known/opencode`).then((x) => x.json()), - )) as { auth: { command: string[]; env: string } } + const wellknown = (yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`).then((x) => x.json()))) as { + auth: { command: string[]; env: string } + } prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" }) if (!proc.stdout) { From adb7cb1037d24aa18021133b5993fa81869d8ba0 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sun, 3 May 2026 19:21:33 +0200 Subject: [PATCH 124/178] fix(auth): add username option for basic auth in RunCommand (#25600) Co-authored-by: Shoubhit Dash --- packages/opencode/src/cli/cmd/run.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index c20833d4be..a05b273e44 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -276,6 +276,11 @@ export const RunCommand = effectCmd({ type: "string", describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", }) + .option("username", { + alias: ["u"], + type: "string", + describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", + }) .option("dir", { type: "string", describe: "directory to run in, path on remote server if attaching", @@ -657,7 +662,7 @@ export const RunCommand = effectCmd({ } if (args.attach) { - const headers = ServerAuth.headers({ password: args.password }) + const headers = ServerAuth.headers({ password: args.password, username: args.username }) const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers }) return await execute(sdk) } From 387220f368ca3a31d94b4be3937d9d825ebd888c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 14:23:29 -0400 Subject: [PATCH 125/178] fix(server): support desktop PTY websockets with HttpApi (#25598) --- packages/app/src/components/terminal.tsx | 28 +- .../src/utils/terminal-websocket-url.test.ts | 36 +++ .../app/src/utils/terminal-websocket-url.ts | 16 ++ packages/opencode/package.json | 5 + .../opencode/src/server/httpapi-listener.ts | 244 ------------------ .../src/server/httpapi-server.node.ts | 34 +++ .../opencode/src/server/httpapi-server.ts | 9 + .../routes/instance/httpapi/handlers/pty.ts | 24 +- .../httpapi/middleware/authorization.ts | 67 +++-- .../instance/httpapi/middleware/proxy.ts | 25 ++ .../instance/httpapi/websocket-tracker.ts | 52 ++++ packages/opencode/src/server/server.ts | 143 +++++++++- packages/opencode/src/util/timeout.ts | 4 +- .../test/server/httpapi-authorization.test.ts | 44 +++- .../test/server/httpapi-listen.test.ts | 155 +++++++++++ .../test/server/httpapi-listener.test.ts | 109 -------- .../test/server/httpapi-mcp-oauth.test.ts | 5 +- 17 files changed, 564 insertions(+), 436 deletions(-) create mode 100644 packages/app/src/utils/terminal-websocket-url.test.ts create mode 100644 packages/app/src/utils/terminal-websocket-url.ts delete mode 100644 packages/opencode/src/server/httpapi-listener.ts create mode 100644 packages/opencode/src/server/httpapi-server.node.ts create mode 100644 packages/opencode/src/server/httpapi-server.ts create mode 100644 packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts create mode 100644 packages/opencode/test/server/httpapi-listen.test.ts delete mode 100644 packages/opencode/test/server/httpapi-listener.test.ts diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index ff5ff9dada..998936bc68 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -15,6 +15,7 @@ import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" +import { terminalWebSocketURL } from "@/utils/terminal-websocket-url" const TOGGLE_TERMINAL_ID = "terminal.toggle" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" @@ -67,13 +68,6 @@ const debugTerminal = (...values: unknown[]) => { console.debug("[terminal]", ...values) } -const errorName = (err: unknown) => { - if (!err || typeof err !== "object") return - if (!("name" in err)) return - const errorName = err.name - return typeof errorName === "string" ? errorName : undefined -} - const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term @@ -478,10 +472,9 @@ export const Terminal = (props: TerminalProps) => { const gone = () => client.pty - .get({ ptyID: id }) - .then(() => false) + .get({ ptyID: id }, { throwOnError: false }) + .then((result) => result.response.status === 404) .catch((err) => { - if (errorName(err) === "NotFoundError") return true debugTerminal("failed to inspect terminal session", err) return false }) @@ -509,18 +502,9 @@ export const Terminal = (props: TerminalProps) => { if (disposed) return drop?.() - const next = new URL(url + `/pty/${id}/connect`) - next.searchParams.set("directory", directory) - next.searchParams.set("cursor", String(seek)) - next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - if (!sameOrigin && password) { - next.searchParams.set("auth_token", btoa(`${username}:${password}`)) - // For same-origin requests, let the browser reuse the page's existing auth. - next.username = username - next.password = password - } - - const socket = new WebSocket(next) + const socket = new WebSocket( + terminalWebSocketURL({ url, id, directory, cursor: seek, sameOrigin, username, password }), + ) socket.binaryType = "arraybuffer" ws = socket diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts new file mode 100644 index 0000000000..c85863abd7 --- /dev/null +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test" +import { terminalWebSocketURL } from "./terminal-websocket-url" + +describe("terminalWebSocketURL", () => { + test("uses query auth without embedding credentials in websocket URL", () => { + const url = terminalWebSocketURL({ + url: "http://127.0.0.1:49365", + id: "pty_test", + directory: "/tmp/project", + cursor: 0, + sameOrigin: false, + username: "opencode", + password: "secret", + }) + + expect(url.protocol).toBe("ws:") + expect(url.username).toBe("") + expect(url.password).toBe("") + expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) + }) + + test("omits query auth for same-origin websocket URL", () => { + const url = terminalWebSocketURL({ + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: true, + username: "opencode", + password: "secret", + }) + + expect(url.protocol).toBe("wss:") + expect(url.searchParams.has("auth_token")).toBe(false) + }) +}) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts new file mode 100644 index 0000000000..146df16b77 --- /dev/null +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -0,0 +1,16 @@ +export function terminalWebSocketURL(input: { + url: string + id: string + directory: string + cursor: number + sameOrigin: boolean + username: string + password?: string +}) { + const next = new URL(`${input.url}/pty/${input.id}/connect`) + next.searchParams.set("directory", input.directory) + next.searchParams.set("cursor", String(input.cursor)) + next.protocol = next.protocol === "https:" ? "wss:" : "ws:" + if (!input.sameOrigin && input.password) next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`)) + return next +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 8c5aa34998..adb4a7db1b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -37,6 +37,11 @@ "bun": "./src/server/adapter.bun.ts", "node": "./src/server/adapter.node.ts", "default": "./src/server/adapter.bun.ts" + }, + "#httpapi-server": { + "bun": "./src/server/httpapi-server.node.ts", + "node": "./src/server/httpapi-server.node.ts", + "default": "./src/server/httpapi-server.node.ts" } }, "devDependencies": { diff --git a/packages/opencode/src/server/httpapi-listener.ts b/packages/opencode/src/server/httpapi-listener.ts deleted file mode 100644 index fd65b0ae67..0000000000 --- a/packages/opencode/src/server/httpapi-listener.ts +++ /dev/null @@ -1,244 +0,0 @@ -// TODO: Node adapter forthcoming — same pattern but using `node:http` + `ws` library, -// and `node:http`'s `upgrade` event. -// -// This module is a Bun-only proof-of-concept for a native `Bun.serve` listener that -// drives the experimental HttpApi handler directly (no Hono in the middle) and handles -// WebSocket upgrades inline based on path-matching. It exists to validate the pattern -// before deleting the Hono backend; `Server.listen()` is intentionally NOT wired to it. - -import type { ServerWebSocket } from "bun" -import { Effect, Schema } from "effect" -import { AppRuntime } from "@/effect/app-runtime" -import { WithInstance } from "@/project/with-instance" -import { Pty } from "@/pty" -import { handlePtyInput } from "@/pty/input" -import { PtyID } from "@/pty/schema" -import { PtyPaths } from "@/server/routes/instance/httpapi/groups/pty" -import { ExperimentalHttpApiServer } from "@/server/routes/instance/httpapi/server" -import * as Log from "@opencode-ai/core/util/log" -import type { CorsOptions } from "./cors" - -const log = Log.create({ service: "httpapi-listener" }) -const decodePtyID = Schema.decodeUnknownSync(PtyID) - -export type Listener = { - hostname: string - port: number - url: URL - stop: (close?: boolean) => Promise -} - -export type ListenOptions = CorsOptions & { - port: number - hostname: string -} - -type WsKind = { kind: "pty"; ptyID: string; cursor: number | undefined; directory: string } - -type PtyHandler = { - onMessage: (message: string | ArrayBuffer) => void - onClose: () => void -} - -type WsState = WsKind & { - handler?: PtyHandler - pending: Array - ready: boolean - closed: boolean -} - -// Derive from the OpenAPI path so this stays in sync if the route literal moves. -const ptyConnectPattern = new RegExp(`^${PtyPaths.connect.replace(/:[^/]+/g, "([^/]+)")}$`) - -function parseCursor(value: string | null): number | undefined { - if (!value) return undefined - const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed < -1) return undefined - return parsed -} - -function asAdapter(ws: ServerWebSocket) { - return { - get readyState() { - return ws.readyState - }, - send: (data: string | Uint8Array | ArrayBuffer) => { - try { - if (data instanceof ArrayBuffer) ws.send(new Uint8Array(data)) - else ws.send(data) - } catch { - // socket likely already closed; ignore - } - }, - close: (code?: number, reason?: string) => { - try { - ws.close(code, reason) - } catch { - // ignore - } - }, - } -} - -/** - * Spin up a native Bun.serve that: - * 1. Routes all HTTP traffic through the HttpApi web handler. - * 2. Intercepts known WebSocket upgrade paths and handles them inline. - * - * This bypasses Hono entirely. The Hono code path remains untouched. - */ -export async function listen(opts: ListenOptions): Promise { - const built = ExperimentalHttpApiServer.webHandler(opts) - const handler = built.handler - const context = ExperimentalHttpApiServer.context - - const start = (port: number) => { - try { - return Bun.serve({ - hostname: opts.hostname, - port, - idleTimeout: 0, - fetch(request, server) { - const url = new URL(request.url) - const ptyMatch = url.pathname.match(ptyConnectPattern) - if (ptyMatch && request.headers.get("upgrade")?.toLowerCase() === "websocket") { - const ptyID = ptyMatch[1]! - const cursor = parseCursor(url.searchParams.get("cursor")) - // Resolve the instance directory the same way the HttpApi - // `instance-context` middleware does (search params, then header, - // then process.cwd()). - const directory = - url.searchParams.get("directory") ?? request.headers.get("x-opencode-directory") ?? process.cwd() - const upgraded = server.upgrade(request, { - data: { - kind: "pty", - ptyID, - cursor, - directory, - pending: [], - ready: false, - closed: false, - } satisfies WsState, - }) - if (upgraded) return undefined - return new Response("upgrade failed", { status: 400 }) - } - - // TODO: workspace-proxy WS upgrade detection. The Hono path forwards via a - // remote `new WebSocket(url, ...)` (see ServerProxy.websocket). To support - // that here we'd need to (a) resolve the workspace target the same way - // `WorkspaceRouterMiddleware` does today, then (b) `server.upgrade(request, - // { data: { kind: "proxy", target, headers, protocols } })` and bridge the - // ServerWebSocket to a remote WebSocket inside the `websocket` handlers. - // Deferred to a follow-up — the proxy story needs more design (auth header - // forwarding, fence sync, reconnection semantics) than fits this PR. - - return handler(request as Request, context as never) - }, - websocket: { - open(ws) { - const data = ws.data - if (data.kind !== "pty") { - ws.close(1011, "unknown ws kind") - return - } - const id = (() => { - try { - return decodePtyID(data.ptyID) - } catch { - ws.close(1008, "invalid pty id") - return undefined - } - })() - if (!id) return - ;(async () => { - const result = await WithInstance.provide({ - directory: data.directory, - fn: () => - AppRuntime.runPromise( - Effect.gen(function* () { - const pty = yield* Pty.Service - return yield* pty.connect(id, asAdapter(ws), data.cursor) - }).pipe(Effect.withSpan("HttpApiListener.pty.connect.open")), - ), - }) - return await result - })() - .then((handler) => { - if (data.closed) { - handler?.onClose() - return - } - if (!handler) { - ws.close(4404, "session not found") - return - } - data.handler = handler - data.ready = true - for (const msg of data.pending) { - AppRuntime.runPromise(handlePtyInput(handler, msg)).catch(() => undefined) - } - data.pending.length = 0 - }) - .catch((err) => { - log.error("pty connect failed", { error: err }) - ws.close(1011, "pty connect failed") - }) - }, - message(ws, message) { - const data = ws.data - if (data.kind !== "pty") return - const payload = - typeof message === "string" - ? message - : message instanceof Buffer - ? new Uint8Array(message.buffer, message.byteOffset, message.byteLength) - : (message as Uint8Array) - if (!data.ready || !data.handler) { - data.pending.push(payload) - return - } - AppRuntime.runPromise(handlePtyInput(data.handler, payload)).catch(() => undefined) - }, - close(ws) { - const data = ws.data - data.closed = true - data.handler?.onClose() - }, - }, - }) - } catch (err) { - log.error("Bun.serve failed", { error: err }) - return undefined - } - } - - const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port) - if (!server) throw new Error(`Failed to start server on port ${opts.port}`) - const port = server.port - if (port === undefined) throw new Error("Bun.serve started without a numeric port") - - const url = new URL("http://localhost") - url.hostname = opts.hostname - url.port = String(port) - - let closing: Promise | undefined - return { - hostname: opts.hostname, - port, - url, - stop(close?: boolean) { - closing ??= (async () => { - await server.stop(close) - // NOTE: we deliberately do NOT call `built.dispose()` here. The - // underlying `webHandler` is memoized at module level (same as the - // Hono path), so disposing it would tear down shared services for - // every other consumer in the process. Lifecycle teardown is owned - // by the AppRuntime itself. - })() - return closing - }, - } -} - -export * as HttpApiListener from "./httpapi-listener" diff --git a/packages/opencode/src/server/httpapi-server.node.ts b/packages/opencode/src/server/httpapi-server.node.ts new file mode 100644 index 0000000000..5d29fae33f --- /dev/null +++ b/packages/opencode/src/server/httpapi-server.node.ts @@ -0,0 +1,34 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import type { Opts } from "./adapter" +import { Service } from "./httpapi-server" + +export { Service } + +export const name = "node-http-server" + +export const layer = (opts: Opts) => { + const server = createServer() + const serverRef = { closeStarted: false, forceStop: false } + const close = server.close.bind(server) + // Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by + // force-closing active HTTP sockets when its finalizer calls server.close(). + server.close = ((callback?: Parameters[0]) => { + serverRef.closeStarted = true + const result = close(callback) + if (serverRef.forceStop) server.closeAllConnections() + return result + }) as typeof server.close + return Layer.mergeAll( + NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }), + Layer.succeed(Service)( + Service.of({ + closeAll: Effect.sync(() => { + serverRef.forceStop = true + if (serverRef.closeStarted) server.closeAllConnections() + }), + }), + ), + ) +} diff --git a/packages/opencode/src/server/httpapi-server.ts b/packages/opencode/src/server/httpapi-server.ts new file mode 100644 index 0000000000..5f3804c107 --- /dev/null +++ b/packages/opencode/src/server/httpapi-server.ts @@ -0,0 +1,9 @@ +import { Context, Effect } from "effect" + +export interface Interface { + readonly closeAll: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/HttpApiServer") {} + +export * as HttpApiServer from "./httpapi-server" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index cc7c385b3e..2e2c4ee1cb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -2,12 +2,14 @@ import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" import { handlePtyInput } from "@/pty/input" import { Shell } from "@/shell/shell" +import { EffectBridge } from "@/effect/bridge" import { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" import { InstanceHttpApi } from "../api" import { CursorQuery, Params, PtyPaths } from "../groups/pty" +import { WebSocketTracker } from "../websocket-tracker" export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => Effect.gen(function* () { @@ -80,9 +82,22 @@ export const ptyConnectRoute = HttpRouter.use((router) => : undefined const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) const write = yield* socket.writer - const services = yield* Effect.context() + const closeAccepted = (event: Socket.CloseEvent) => + socket + .runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) }) + .pipe( + Effect.timeout("1 second"), + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.catch(() => Effect.void), + ) + const registered = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT())) + if (!registered) { + yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT()) + return HttpServerResponse.empty() + } + const bridge = yield* EffectBridge.make() const writeScoped = (effect: Effect.Effect) => { - Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void))) + bridge.fork(effect.pipe(Effect.catch(() => Effect.void))) } let closed = false const adapter = { @@ -100,7 +115,10 @@ export const ptyConnectRoute = HttpRouter.use((router) => }, } const handler = yield* pty.connect(params.ptyID, adapter, cursor) - if (!handler) return HttpServerResponse.empty() + if (!handler) { + yield* closeAccepted(new Socket.CloseEvent(4404, "session not found")) + return HttpServerResponse.empty() + } yield* socket .runRaw((message) => handlePtyInput(handler, message)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index bd9552edcd..2a8f1cf4d4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,23 +1,29 @@ import { ServerAuth } from "@/server/auth" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" +import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 const WWW_AUTHENTICATE = 'Basic realm="Secure Area"' +// Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the +// full handler, so a downstream failure can make the next auth alternative run +// and remap an authorized NotFound into Unauthorized. export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", { error: HttpApiError.UnauthorizedNoContent, - security: { - basic: HttpApiSecurity.basic, - authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }), - }, }, ) {} +function emptyCredential() { + return { + username: "", + password: Redacted.make(""), + } +} + function validateCredential( effect: Effect.Effect, credential: ServerAuth.DecodedCredentials, @@ -31,19 +37,14 @@ function validateCredential( } function decodeCredential(input: string) { - const emptyCredential = { - username: "", - password: Redacted.make(""), - } - return Encoding.decodeBase64String(input) .asEffect() .pipe( Effect.match({ - onFailure: () => emptyCredential, + onFailure: emptyCredential, onSuccess: (header) => { const parts = header.split(":") - if (parts.length !== 2) return emptyCredential + if (parts.length !== 2) return emptyCredential() return { username: parts[0], password: Redacted.make(parts[1]), @@ -53,6 +54,14 @@ function decodeCredential(input: string) { ) } +function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) { + const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY) + if (token) return decodeCredential(token) + const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") + if (match) return decodeCredential(match[1]) + return Effect.succeed(emptyCredential()) +} + function validateRawCredential( effect: Effect.Effect, credential: ServerAuth.DecodedCredentials, @@ -77,21 +86,9 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( return (effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") - if (match) { - return yield* decodeCredential(match[1]).pipe( - Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), - ) - } - - const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY) - if (token) { - return yield* decodeCredential(token).pipe( - Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), - ) - } - - return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config) + return yield* credentialFromRequest(request).pipe( + Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), + ) }) }), ) @@ -100,12 +97,14 @@ export const authorizationLayer = Layer.effect( Authorization, Effect.gen(function* () { const config = yield* ServerAuth.Config - return Authorization.of({ - basic: (effect, { credential }) => validateCredential(effect, credential, config), - authToken: (effect, { credential }) => - decodeCredential(Redacted.value(credential)).pipe( - Effect.flatMap((decoded) => validateCredential(effect, decoded, config)), - ), - }) + if (!ServerAuth.required(config)) return Authorization.of((effect) => effect) + return Authorization.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + return yield* credentialFromRequest(request).pipe( + Effect.flatMap((credential) => validateCredential(effect, credential, config)), + ) + }), + ) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index e354dccbfa..0a1745f937 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -2,6 +2,7 @@ import { ProxyUtil } from "@/server/proxy-util" import { Effect, Stream } from "effect" import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" +import { WebSocketTracker } from "../websocket-tracker" function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined { return request.source instanceof Request ? request.source : undefined @@ -28,6 +29,30 @@ export function websocket( }) const writeInbound = yield* inbound.writer const writeOutbound = yield* outbound.writer + const closeSocket = (socket: Socket.Socket, write: (event: Socket.CloseEvent) => Effect.Effect) => + socket + .runRaw(() => Effect.void, { + onOpen: write(WebSocketTracker.SERVER_CLOSING_EVENT()).pipe(Effect.catch(() => Effect.void)), + }) + .pipe( + Effect.timeout("1 second"), + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.catch(() => Effect.void), + ) + const closeAccepted = Effect.all( + [closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], + { concurrency: "unbounded", discard: true }, + ) + const registered = yield* WebSocketTracker.register( + Effect.all( + [writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()), writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT())], + { concurrency: "unbounded", discard: true }, + ), + ) + if (!registered) { + yield* closeAccepted + return HttpServerResponse.empty() + } yield* outbound .runRaw((message) => writeInbound(message)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts new file mode 100644 index 0000000000..4463c9c590 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts @@ -0,0 +1,52 @@ +import { Context, Effect, Layer, Option } from "effect" +import * as Socket from "effect/unstable/socket/Socket" + +export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing") + +type Close = Effect.Effect + +export interface Interface { + readonly add: (close: Close) => Effect.Effect + readonly remove: (close: Close) => Effect.Effect + readonly closeAll: Effect.Effect +} + +export class Service extends Context.Service()("@opencode/HttpApiWebSocketTracker") {} + +export const layer = Layer.sync(Service)(() => { + const sockets = new Set() + let closing = false + return Service.of({ + add: (close) => + Effect.gen(function* () { + if (closing) return false + sockets.add(close) + return true + }), + remove: (close) => + Effect.sync(() => { + sockets.delete(close) + }), + closeAll: Effect.gen(function* () { + closing = true + const active = Array.from(sockets) + sockets.clear() + yield* Effect.all( + active.map((close) => close.pipe(Effect.timeout("1 second"), Effect.catch(() => Effect.void))), + { concurrency: "unbounded", discard: true }, + ) + }), + }) +}) + +export const register = (close: Close) => + Effect.gen(function* () { + const tracker = yield* Effect.serviceOption(Service) + if (Option.isNone(tracker)) return true + const registered = yield* tracker.value.add(close) + if (!registered) return false + yield* Effect.addFinalizer(() => tracker.value.remove(close)) + return true + }) + +export * as WebSocketTracker from "./websocket-tracker" diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 13ec706163..0383dc66f6 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,7 +5,10 @@ import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { WorkspaceID } from "@/control-plane/schema" +import { Context, Effect, Exit, Layer, Scope } from "effect" +import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" +import * as HttpApiServer from "#httpapi-server" import { MDNS } from "./mdns" import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware" import { FenceMiddleware } from "./fence" @@ -18,6 +21,8 @@ import { WorkspaceRouterMiddleware } from "./workspace" import { InstanceMiddleware } from "./routes/instance/middleware" import { WorkspaceRoutes } from "./routes/control/workspace" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" +import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" +import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" import { PublicApi } from "./routes/instance/httpapi/public" import * as ServerBackend from "./backend" import type { CorsOptions } from "./cors" @@ -182,37 +187,147 @@ export async function openapiHono() { export let url: URL export async function listen(opts: ListenOptions): Promise { - const built = create(opts) - const server = await built.runtime.listen(opts) + const selected = select() + const inner: Listener = + selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts) - const next = new URL("http://localhost") - next.hostname = opts.hostname - next.port = String(server.port) + const next = new URL(inner.url) url = next const mdns = opts.mdns && - server.port && + inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (mdns) { - MDNS.publish(server.port, opts.mdnsDomain) + MDNS.publish(inner.port, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } let closing: Promise | undefined + let mdnsUnpublished = false + const unpublish = () => { + if (!mdns || mdnsUnpublished) return + mdnsUnpublished = true + MDNS.unpublish() + } return { - hostname: opts.hostname, - port: server.port, + hostname: inner.hostname, + port: inner.port, url: next, stop(close?: boolean) { - closing ??= (async () => { - if (mdns) MDNS.unpublish() - await server.stop(close) - })() - return closing + unpublish() + // Always forward stop(true), even if a graceful stop was requested + // first, so native listeners can escalate shutdown in-place. + const next = inner.stop(close) + closing ??= next + return close ? next.then(() => closing!) : closing + }, + } +} + +async function listenLegacy(opts: ListenOptions): Promise { + const built = create(opts) + const server = await built.runtime.listen(opts) + const innerUrl = new URL("http://localhost") + innerUrl.hostname = opts.hostname + innerUrl.port = String(server.port) + return { + hostname: opts.hostname, + port: server.port, + url: innerUrl, + stop: (close?: boolean) => server.stop(close), + } +} + +/** + * Run the effect-httpapi backend on a native Effect HTTP server. This + * lets HttpApi routes that call `request.upgrade` (PTY connect, the + * workspace-routing proxy WS bridge) work end-to-end; the legacy Hono + * adapter path can't surface `request.upgrade` because its fetch handler has + * no reference to the platform server instance for websocket upgrades. + */ +async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise { + log.info("server backend selected", { + ...ServerBackend.attributes(selection), + "opencode.server.runtime": HttpApiServer.name, + }) + + const buildLayer = (port: number) => + HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), { + middleware: disposeMiddleware, + disableLogger: true, + disableListenLog: true, + }).pipe( + Layer.provideMerge(WebSocketTracker.layer), + Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })), + ) + + const start = async (port: number) => { + const scope = Scope.makeUnsafe() + try { + // Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by + // design, which leaks `R = any` through `HttpRouter.serve`. The actual + // requirements at this point are fully satisfied by `createRoutes` and the + // platform HTTP server layer; cast away the `any` to satisfy `runPromise`. + const layer = buildLayer(port) as Layer.Layer< + HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service, + unknown, + never + > + const ctx = await Effect.runPromise(Layer.buildWithMemoMap(layer, Layer.makeMemoMapUnsafe(), scope)) + return { scope, ctx } + } catch (err) { + await Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => undefined) + throw err + } + } + + // Match the legacy adapter port-resolution behavior: explicit `0` prefers + // 4096 first, then any free port. + let resolved: Awaited> | undefined + if (opts.port === 0) { + resolved = await start(4096).catch(() => undefined) + if (!resolved) resolved = await start(0) + } else { + resolved = await start(opts.port) + } + if (!resolved) throw new Error(`Failed to start server on port ${opts.port}`) + + const server = Context.get(resolved.ctx, HttpServer.HttpServer) + if (server.address._tag !== "TcpAddress") { + await Effect.runPromise(Scope.close(resolved.scope, Exit.void)) + throw new Error(`Unexpected HttpServer address tag: ${server.address._tag}`) + } + const port = server.address.port + + const innerUrl = new URL("http://localhost") + innerUrl.hostname = opts.hostname + innerUrl.port = String(port) + let forceStopPromise: Promise | undefined + let stopPromise: Promise | undefined + const forceStop = () => { + forceStopPromise ??= Effect.runPromiseExit( + Effect.gen(function* () { + yield* Context.get(resolved!.ctx, HttpApiServer.Service).closeAll + yield* Context.get(resolved!.ctx, WebSocketTracker.Service).closeAll + }), + ).then(() => undefined) + return forceStopPromise + } + + return { + hostname: opts.hostname, + port, + url: innerUrl, + stop: (close?: boolean) => { + const requested = close ? forceStop() : Promise.resolve() + // The first call starts scope shutdown. A later stop(true) cannot undo + // that, but it still runs forceStop() before awaiting the original close. + stopPromise ??= requested.then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))).then(() => undefined) + return requested.then(() => stopPromise!) }, } } diff --git a/packages/opencode/src/util/timeout.ts b/packages/opencode/src/util/timeout.ts index 31ac481468..22f2648c92 100644 --- a/packages/opencode/src/util/timeout.ts +++ b/packages/opencode/src/util/timeout.ts @@ -1,4 +1,4 @@ -export function withTimeout(promise: Promise, ms: number): Promise { +export function withTimeout(promise: Promise, ms: number, label?: string): Promise { let timeout: NodeJS.Timeout return Promise.race([ promise.finally(() => { @@ -6,7 +6,7 @@ export function withTimeout(promise: Promise, ms: number): Promise { }), new Promise((_, reject) => { timeout = setTimeout(() => { - reject(new Error(`Operation timed out after ${ms}ms`)) + reject(new Error(label ?? `Operation timed out after ${ms}ms`)) }, ms) }), ]) diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index d780b18f24..850098926a 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -2,7 +2,7 @@ import { NodeHttpServer } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi" import { ServerAuth } from "../../src/server/auth" import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { testEffect } from "../lib/effect" @@ -13,11 +13,19 @@ const Api = HttpApi.make("test-authorization").add( HttpApiEndpoint.get("probe", "/probe", { success: Schema.String, }), + HttpApiEndpoint.get("missing", "/missing", { + success: Schema.String, + error: HttpApiError.NotFound, + }), ) .middleware(Authorization), ) -const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok"))) +const handlers = HttpApiBuilder.group(Api, "test", (handlers) => + handlers + .handle("probe", () => Effect.succeed("ok")) + .handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))), +) const apiLayer = HttpRouter.serve( HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)), @@ -32,8 +40,7 @@ const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer))) const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer))) const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer))) -const basic = (username: string, password: string) => - `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` +const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? "" const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64") @@ -90,6 +97,35 @@ describe("HttpApi authorization middleware", () => { }), ) + itSecret.live("prefers auth token query credentials over basic auth", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.get( + `/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`, + ).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute) + + expect(response.status).toBe(200) + }), + ) + + itSecret.live("preserves handler errors when basic auth succeeds", () => + Effect.gen(function* () { + const response = yield* HttpClientRequest.get("/missing").pipe( + HttpClientRequest.setHeader("authorization", basic("opencode", "secret")), + HttpClient.execute, + ) + + expect(response.status).toBe(404) + }), + ) + + itSecret.live("preserves handler errors when auth token query succeeds", () => + Effect.gen(function* () { + const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`) + + expect(response.status).toBe(404) + }), + ) + itSecret.live("rejects malformed auth token query credentials", () => Effect.gen(function* () { const response = yield* HttpClient.get("/probe?auth_token=not-base64") diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts new file mode 100644 index 0000000000..3ee57dc108 --- /dev/null +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { Server } from "../../src/server/server" +import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" + +void Log.init({ print: false }) + +const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, + envPassword: process.env.OPENCODE_SERVER_PASSWORD, + envUsername: process.env.OPENCODE_SERVER_USERNAME, +} +const auth = { username: "opencode", password: "listen-secret" } +const testPty = process.platform === "win32" ? test.skip : test + +afterEach(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD + else process.env.OPENCODE_SERVER_PASSWORD = original.envPassword + if (original.envUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME + else process.env.OPENCODE_SERVER_USERNAME = original.envUsername + await disposeAllInstances() + await resetDatabase() +}) + +async function startListener() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_SERVER_PASSWORD = auth.password + Flag.OPENCODE_SERVER_USERNAME = auth.username + process.env.OPENCODE_SERVER_PASSWORD = auth.password + process.env.OPENCODE_SERVER_USERNAME = auth.username + return Server.listen({ hostname: "127.0.0.1", port: 0 }) +} + +function authorization() { + return `Basic ${btoa(`${auth.username}:${auth.password}`)}` +} + +function socketURL(listener: Awaited>, id: string, dir: string) { + const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url) + url.protocol = "ws:" + url.searchParams.set("directory", dir) + url.searchParams.set("cursor", "-1") + url.searchParams.set("auth_token", btoa(`${auth.username}:${auth.password}`)) + return url +} + +async function createCat(listener: Awaited>, dir: string) { + const response = await fetch(new URL(PtyPaths.create, listener.url), { + method: "POST", + headers: { + authorization: authorization(), + "x-opencode-directory": dir, + "content-type": "application/json", + }, + body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }), + }) + expect(response.status).toBe(200) + return (await response.json()) as { id: string } +} + +async function openSocket(url: URL) { + const ws = new WebSocket(url) + ws.binaryType = "arraybuffer" + await withTimeout( + new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }) + ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true }) + }), + 5_000, + "timed out waiting for websocket open", + ) + return ws +} + +function stop(listener: Awaited>, label: string) { + return withTimeout(listener.stop(true), 10_000, label) +} + +function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) { + const decoder = new TextDecoder() + let onMessage: ((event: MessageEvent) => void) | undefined + return withTimeout( + new Promise((resolve) => { + onMessage = (event: MessageEvent) => { + const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer) + if (!predicate(message)) return + resolve(message) + } + ws.addEventListener("message", onMessage) + }), + 5_000, + "timed out waiting for websocket message", + ).finally(() => { + if (onMessage) ws.removeEventListener("message", onMessage) + }) +} + +describe("HttpApi Server.listen", () => { + testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + let stopped = false + try { + const response = await fetch(new URL(PtyPaths.shells, listener.url), { + headers: { authorization: authorization(), "x-opencode-directory": tmp.path }, + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: expect.any(String), + name: expect.any(String), + acceptable: expect.any(Boolean), + }), + ]), + ) + + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const closed = new Promise((resolve) => ws.addEventListener("close", () => resolve(), { once: true })) + + const message = waitForMessage(ws, (message) => message.includes("ping-listen")) + ws.send("ping-listen\n") + expect(await message).toContain("ping-listen") + + await stop(listener, "timed out waiting for listener.stop(true)") + stopped = true + await withTimeout(closed, 5_000, "timed out waiting for websocket close") + expect(ws.readyState).toBe(WebSocket.CLOSED) + + const restarted = await startListener() + try { + const nextInfo = await createCat(restarted, tmp.path) + const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path)) + const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted")) + nextWs.send("ping-restarted\n") + expect(await nextMessage).toContain("ping-restarted") + nextWs.close(1000) + } finally { + await stop(restarted, "timed out waiting for restarted listener.stop(true)") + } + } finally { + if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined) + } + }) +}) diff --git a/packages/opencode/test/server/httpapi-listener.test.ts b/packages/opencode/test/server/httpapi-listener.test.ts deleted file mode 100644 index de7b5987ec..0000000000 --- a/packages/opencode/test/server/httpapi-listener.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Flag } from "@opencode-ai/core/flag/flag" -import * as Log from "@opencode-ai/core/util/log" -import { resetDatabase } from "../fixture/db" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" -import { HttpApiListener } from "../../src/server/httpapi-listener" -import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" - -void Log.init({ print: false }) - -const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI -const testPty = process.platform === "win32" ? test.skip : test - -afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original - await disposeAllInstances() - await resetDatabase() -}) - -async function startListener() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true - return HttpApiListener.listen({ hostname: "127.0.0.1", port: 0 }) -} - -describe("native HttpApi listener", () => { - test("serves HTTP routes via the HttpApi web handler", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startListener() - try { - const response = await fetch(`${listener.url.origin}${PtyPaths.shells}`, { - headers: { "x-opencode-directory": tmp.path }, - }) - expect(response.status).toBe(200) - const body = await response.json() - expect(Array.isArray(body)).toBe(true) - expect(body[0]).toMatchObject({ - path: expect.any(String), - name: expect.any(String), - acceptable: expect.any(Boolean), - }) - } finally { - await listener.stop(true) - } - }) - - testPty("PTY websocket connect echoes input back to the client", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startListener() - try { - const created = await fetch(`${listener.url.origin}${PtyPaths.create}`, { - method: "POST", - headers: { - "x-opencode-directory": tmp.path, - "content-type": "application/json", - }, - body: JSON.stringify({ command: "/bin/cat", title: "listener-smoke" }), - }) - expect(created.status).toBe(200) - const info = (await created.json()) as { id: string } - - try { - const wsURL = new URL(PtyPaths.connect.replace(":ptyID", info.id), listener.url) - wsURL.protocol = "ws:" - wsURL.searchParams.set("directory", tmp.path) - wsURL.searchParams.set("cursor", "-1") - - const messages: string[] = [] - const ws = new WebSocket(wsURL) - ws.binaryType = "arraybuffer" - - const opened = new Promise((resolve, reject) => { - ws.addEventListener("open", () => resolve(), { once: true }) - ws.addEventListener("error", () => reject(new Error("ws error before open")), { once: true }) - }) - - const closed = new Promise((resolve) => { - ws.addEventListener("close", () => resolve(), { once: true }) - }) - - ws.addEventListener("message", (event) => { - const data = event.data - messages.push(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer)) - }) - - await opened - ws.send("ping-listener\n") - - const start = Date.now() - while (!messages.some((m) => m.includes("ping-listener")) && Date.now() - start < 5_000) { - await new Promise((r) => setTimeout(r, 50)) - } - ws.close(1000, "done") - - expect(messages.some((m) => m.includes("ping-listener"))).toBe(true) - // Verify close event fires (handler.onClose path runs and the - // Bun.serve websocket lifecycle reaches close). - await closed - expect(ws.readyState).toBe(WebSocket.CLOSED) - } finally { - await fetch(`${listener.url.origin}${PtyPaths.remove.replace(":ptyID", info.id)}`, { - method: "DELETE", - headers: { "x-opencode-directory": tmp.path }, - }).catch(() => undefined) - } - } finally { - await listener.stop(true) - } - }) -}) diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts index 829f899605..d3ca4ae683 100644 --- a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -33,10 +33,7 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) => const passthroughAuthorization = Layer.succeed( Authorization, - Authorization.of({ - basic: (effect) => effect, - authToken: (effect) => effect, - }), + Authorization.of((effect) => effect), ) const passthroughInstanceContext = Layer.succeed( From 28112fbd12d16d21563eead2a188e0ecae11303e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 18:24:37 +0000 Subject: [PATCH 126/178] chore: generate --- packages/app/src/utils/terminal-websocket-url.ts | 3 ++- .../routes/instance/httpapi/middleware/proxy.ts | 13 ++++++++----- .../routes/instance/httpapi/websocket-tracker.ts | 7 ++++++- packages/opencode/src/server/server.ts | 10 ++++------ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index 146df16b77..d364762d7e 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -11,6 +11,7 @@ export function terminalWebSocketURL(input: { next.searchParams.set("directory", input.directory) next.searchParams.set("cursor", String(input.cursor)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - if (!input.sameOrigin && input.password) next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`)) + if (!input.sameOrigin && input.password) + next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`)) return next } diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index 0a1745f937..230f5b105b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -39,13 +39,16 @@ export function websocket( Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), Effect.catch(() => Effect.void), ) - const closeAccepted = Effect.all( - [closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], - { concurrency: "unbounded", discard: true }, - ) + const closeAccepted = Effect.all([closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], { + concurrency: "unbounded", + discard: true, + }) const registered = yield* WebSocketTracker.register( Effect.all( - [writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()), writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT())], + [ + writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()), + writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT()), + ], { concurrency: "unbounded", discard: true }, ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts index 4463c9c590..7cbac4ed5f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts @@ -32,7 +32,12 @@ export const layer = Layer.sync(Service)(() => { const active = Array.from(sockets) sockets.clear() yield* Effect.all( - active.map((close) => close.pipe(Effect.timeout("1 second"), Effect.catch(() => Effect.void))), + active.map((close) => + close.pipe( + Effect.timeout("1 second"), + Effect.catch(() => Effect.void), + ), + ), { concurrency: "unbounded", discard: true }, ) }), diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 0383dc66f6..6c7a6743db 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -195,11 +195,7 @@ export async function listen(opts: ListenOptions): Promise { url = next const mdns = - opts.mdns && - inner.port && - opts.hostname !== "127.0.0.1" && - opts.hostname !== "localhost" && - opts.hostname !== "::1" + opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1" if (mdns) { MDNS.publish(inner.port, opts.mdnsDomain) } else if (opts.mdns) { @@ -326,7 +322,9 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec const requested = close ? forceStop() : Promise.resolve() // The first call starts scope shutdown. A later stop(true) cannot undo // that, but it still runs forceStop() before awaiting the original close. - stopPromise ??= requested.then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))).then(() => undefined) + stopPromise ??= requested + .then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))) + .then(() => undefined) return requested.then(() => stopPromise!) }, } From 7749d8e85f2bf4879ee98af90066c167153bb19b Mon Sep 17 00:00:00 2001 From: Dax Date: Sun, 3 May 2026 14:45:48 -0400 Subject: [PATCH 127/178] Add v2 session failure events (#25628) --- .../src/cli/cmd/tui/context/sync-v2.tsx | 11 +++- packages/opencode/src/session/processor.ts | 13 ++++- .../opencode/src/session/projectors-next.ts | 7 ++- packages/opencode/src/v2/session-event.ts | 29 ++++++++--- .../src/v2/session-message-updater.ts | 13 ++++- packages/opencode/src/v2/session-message.ts | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 51 ++++++++++++++++--- 7 files changed, 104 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index f82bb4d962..9801f0a2f8 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -143,6 +143,15 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } }) break + case "session.next.step.failed": + update(event.properties.sessionID, (draft) => { + const currentAssistant = activeAssistant(draft) + if (!currentAssistant) return + currentAssistant.time.completed = event.properties.timestamp + currentAssistant.finish = "error" + currentAssistant.error = event.properties.error + }) + break case "session.next.text.started": update(event.properties.sessionID, (draft) => { activeAssistant(draft)?.content.push({ type: "text", text: "" }) @@ -210,7 +219,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( match.time.completed = event.properties.timestamp }) break - case "session.next.tool.error": + case "session.next.tool.failed": update(event.properties.sessionID, (draft) => { const match = latestTool(activeAssistant(draft), event.properties.callID) if (match?.state.status !== "running") return diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index e2a47f1800..cf1a7e0ae9 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -405,7 +405,7 @@ export const layer: Layer.Layer< case "tool-error": { const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - EventV2.run(SessionEvent.Tool.Error.Sync, { + EventV2.run(SessionEvent.Tool.Failed.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, error: { @@ -650,6 +650,17 @@ export const layer: Layer.Layer< yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) return } + if (!ctx.assistantMessage.summary) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + EventV2.run(SessionEvent.Step.Failed.Sync, { + sessionID: ctx.sessionID, + error: { + type: error.name, + message: errorMessage(e), + }, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } ctx.assistantMessage.error = error yield* bus.publish(Session.Event.Error, { sessionID: ctx.assistantMessage.sessionID, diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index 951e3e874f..88f73acf1a 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -161,6 +161,9 @@ export default [ SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data }) }), + SyncEvent.project(SessionEvent.Step.Failed.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data }) + }), SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data }) }), @@ -181,8 +184,8 @@ export default [ SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data }) }), - SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => { - update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data }) + SyncEvent.project(SessionEvent.Tool.Failed.Sync, (db, data, event) => { + update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data }) }), SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => { update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data }) diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 3af5932f0d..47938dcbed 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -22,6 +22,11 @@ const Base = { sessionID: SessionID, } +const Error = Schema.Struct({ + type: Schema.String, + message: Schema.String, +}) + export const AgentSwitched = EventV2.define({ type: "session.next.agent.switched", aggregate: "sessionID", @@ -128,6 +133,16 @@ export namespace Step { }, }) export type Ended = Schema.Schema.Type + + export const Failed = EventV2.define({ + type: "session.next.step.failed", + aggregate: "sessionID", + schema: { + ...Base, + error: Error, + }, + }) + export type Failed = Schema.Schema.Type } export namespace Text { @@ -275,23 +290,20 @@ export namespace Tool { }) export type Success = Schema.Schema.Type - export const Error = EventV2.define({ - type: "session.next.tool.error", + export const Failed = EventV2.define({ + type: "session.next.tool.failed", aggregate: "sessionID", schema: { ...Base, callID: Schema.String, - error: Schema.Struct({ - type: Schema.String, - message: Schema.String, - }), + error: Error, provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }), }, }) - export type Error = Schema.Schema.Type + export type Failed = Schema.Schema.Type } export const RetryError = Schema.Struct({ @@ -359,6 +371,7 @@ export const All = Schema.Union( Shell.Ended, Step.Started, Step.Ended, + Step.Failed, Text.Started, Text.Delta, Text.Ended, @@ -368,7 +381,7 @@ export const All = Schema.Union( Tool.Called, Tool.Progress, Tool.Success, - Tool.Error, + Tool.Failed, Reasoning.Started, Reasoning.Delta, Reasoning.Ended, diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index ad1aa32e70..d5d5aac7b7 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -199,6 +199,17 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) } }, + "session.next.step.failed": (event) => { + if (currentAssistant) { + adapter.updateAssistant( + produce(currentAssistant, (draft) => { + draft.time.completed = event.data.timestamp + draft.finish = "error" + draft.error = event.data.error + }), + ) + } + }, "session.next.text.started": () => { if (currentAssistant) { adapter.updateAssistant( @@ -314,7 +325,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve ) } }, - "session.next.tool.error": (event) => { + "session.next.tool.failed": (event) => { if (currentAssistant) { adapter.updateAssistant( produce(currentAssistant, (draft) => { diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 8ec99bc200..94f6b1cac2 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -152,7 +152,7 @@ export class Assistant extends Schema.Class("Session.Message.Assistan write: Schema.Finite, }), }).pipe(Schema.optional), - error: Schema.String.pipe(Schema.optional), + error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index caa3d4c767..79ef42d9e1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -58,6 +58,7 @@ export type Event = | EventSessionNextShellEnded | EventSessionNextStepStarted | EventSessionNextStepEnded + | EventSessionNextStepFailed | EventSessionNextTextStarted | EventSessionNextTextDelta | EventSessionNextTextEnded @@ -70,7 +71,7 @@ export type Event = | EventSessionNextToolCalled | EventSessionNextToolProgress | EventSessionNextToolSuccess - | EventSessionNextToolError + | EventSessionNextToolFailed | EventSessionNextRetried | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta @@ -823,6 +824,7 @@ export type GlobalEvent = { | EventSessionNextShellEnded | EventSessionNextStepStarted | EventSessionNextStepEnded + | EventSessionNextStepFailed | EventSessionNextTextStarted | EventSessionNextTextDelta | EventSessionNextTextEnded @@ -835,7 +837,7 @@ export type GlobalEvent = { | EventSessionNextToolCalled | EventSessionNextToolProgress | EventSessionNextToolSuccess - | EventSessionNextToolError + | EventSessionNextToolFailed | EventSessionNextRetried | EventSessionNextCompactionStarted | EventSessionNextCompactionDelta @@ -857,6 +859,7 @@ export type GlobalEvent = { | SyncEventSessionNextShellEnded | SyncEventSessionNextStepStarted | SyncEventSessionNextStepEnded + | SyncEventSessionNextStepFailed | SyncEventSessionNextTextStarted | SyncEventSessionNextTextDelta | SyncEventSessionNextTextEnded @@ -869,7 +872,7 @@ export type GlobalEvent = { | SyncEventSessionNextToolCalled | SyncEventSessionNextToolProgress | SyncEventSessionNextToolSuccess - | SyncEventSessionNextToolError + | SyncEventSessionNextToolFailed | SyncEventSessionNextRetried | SyncEventSessionNextCompactionStarted | SyncEventSessionNextCompactionDelta @@ -1973,6 +1976,22 @@ export type SyncEventSessionNextStepEnded = { } } +export type SyncEventSessionNextStepFailed = { + type: "sync" + name: "session.next.step.failed.1" + id: string + seq: number + aggregateID: "sessionID" + data: { + timestamp: number + sessionID: string + error: { + type: string + message: string + } + } +} + export type SyncEventSessionNextTextStarted = { type: "sync" name: "session.next.text.started.1" @@ -2157,9 +2176,9 @@ export type SyncEventSessionNextToolSuccess = { } } -export type SyncEventSessionNextToolError = { +export type SyncEventSessionNextToolFailed = { type: "sync" - name: "session.next.tool.error.1" + name: "session.next.tool.failed.1" id: string seq: number aggregateID: "sessionID" @@ -2710,6 +2729,19 @@ export type EventSessionNextStepEnded = { } } +export type EventSessionNextStepFailed = { + id: string + type: "session.next.step.failed" + properties: { + timestamp: number + sessionID: string + error: { + type: string + message: string + } + } +} + export type EventSessionNextTextStarted = { id: string type: "session.next.text.started" @@ -2870,9 +2902,9 @@ export type EventSessionNextToolSuccess = { } } -export type EventSessionNextToolError = { +export type EventSessionNextToolFailed = { id: string - type: "session.next.tool.error" + type: "session.next.tool.failed" properties: { timestamp: number sessionID: string @@ -3162,7 +3194,10 @@ export type SessionMessageAssistant = { write: number } } - error?: string + error?: { + type: string + message: string + } } export type SessionMessageCompaction = { From a9dc0fae3d808baf3cbb6f5529877da20db164e7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 18:46:50 +0000 Subject: [PATCH 128/178] chore: generate --- packages/sdk/openapi.json | 126 +++++++++++++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 8 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index df00c17266..21c547c853 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8512,6 +8512,9 @@ { "$ref": "#/components/schemas/EventSessionNextStepEnded" }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, { "$ref": "#/components/schemas/EventSessionNextTextStarted" }, @@ -8549,7 +8552,7 @@ "$ref": "#/components/schemas/EventSessionNextToolSuccess" }, { - "$ref": "#/components/schemas/EventSessionNextToolError" + "$ref": "#/components/schemas/EventSessionNextToolFailed" }, { "$ref": "#/components/schemas/EventSessionNextRetried" @@ -10708,6 +10711,9 @@ { "$ref": "#/components/schemas/EventSessionNextStepEnded" }, + { + "$ref": "#/components/schemas/EventSessionNextStepFailed" + }, { "$ref": "#/components/schemas/EventSessionNextTextStarted" }, @@ -10745,7 +10751,7 @@ "$ref": "#/components/schemas/EventSessionNextToolSuccess" }, { - "$ref": "#/components/schemas/EventSessionNextToolError" + "$ref": "#/components/schemas/EventSessionNextToolFailed" }, { "$ref": "#/components/schemas/EventSessionNextRetried" @@ -10810,6 +10816,9 @@ { "$ref": "#/components/schemas/SyncEventSessionNextStepEnded" }, + { + "$ref": "#/components/schemas/SyncEventSessionNextStepFailed" + }, { "$ref": "#/components/schemas/SyncEventSessionNextTextStarted" }, @@ -10847,7 +10856,7 @@ "$ref": "#/components/schemas/SyncEventSessionNextToolSuccess" }, { - "$ref": "#/components/schemas/SyncEventSessionNextToolError" + "$ref": "#/components/schemas/SyncEventSessionNextToolFailed" }, { "$ref": "#/components/schemas/SyncEventSessionNextRetried" @@ -14161,6 +14170,57 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, + "SyncEventSessionNextStepFailed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["sync"] + }, + "name": { + "type": "string", + "enum": ["session.next.step.failed.1"] + }, + "id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "aggregateID": { + "type": "string", + "enum": ["sessionID"] + }, + "data": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["type", "name", "id", "seq", "aggregateID", "data"], + "additionalProperties": false + }, "SyncEventSessionNextTextStarted": { "type": "object", "properties": { @@ -14729,7 +14789,7 @@ "required": ["type", "name", "id", "seq", "aggregateID", "data"], "additionalProperties": false }, - "SyncEventSessionNextToolError": { + "SyncEventSessionNextToolFailed": { "type": "object", "properties": { "type": { @@ -14738,7 +14798,7 @@ }, "name": { "type": "string", - "enum": ["session.next.tool.error.1"] + "enum": ["session.next.tool.failed.1"] }, "id": { "type": "string" @@ -16399,6 +16459,46 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "EventSessionNextStepFailed": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["session.next.step.failed"] + }, + "properties": { + "type": "object", + "properties": { + "timestamp": { + "type": "number" + }, + "sessionID": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + } + }, + "required": ["timestamp", "sessionID", "error"], + "additionalProperties": false + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, "EventSessionNextTextStarted": { "type": "object", "properties": { @@ -16869,7 +16969,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventSessionNextToolError": { + "EventSessionNextToolFailed": { "type": "object", "properties": { "id": { @@ -16877,7 +16977,7 @@ }, "type": { "type": "string", - "enum": ["session.next.tool.error"] + "enum": ["session.next.tool.failed"] }, "properties": { "type": "object", @@ -17700,7 +17800,17 @@ "additionalProperties": false }, "error": { - "type": "string" + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false } }, "required": ["id", "time", "type", "agent", "model", "content"], From 6312c55d55e83a3d9a68ffd56f9cc4298b245901 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 15:44:23 -0400 Subject: [PATCH 129/178] fix(server): serve embedded UI from bunfs (#25632) --- packages/opencode/src/server/shared/ui.ts | 39 ++++++++++++------- .../opencode/test/server/httpapi-ui.test.ts | 35 ++++++++++++++++- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index db67749e08..c1558a1a4e 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -45,6 +45,31 @@ export function embeddedUI() { return embeddedUIPromise } +function notFound() { + return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) +} + +function embeddedUIResponse(file: string, body: Uint8Array) { + const mime = AppFileSystem.mimeType(file) + const headers = new Headers({ "content-type": mime }) + if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) + return HttpServerResponse.raw(body, { headers }) +} + +export function serveEmbeddedUIEffect( + requestPath: string, + fs: AppFileSystem.Interface, + embeddedWebUI: Record, +) { + const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null + if (!file) return Effect.succeed(notFound()) + + return fs.readFile(file).pipe( + Effect.map((body) => embeddedUIResponse(file, body)), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())), + ) +} + export function serveUIEffect( request: HttpServerRequest.HttpServerRequest, services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient }, @@ -53,19 +78,7 @@ export function serveUIEffect( const embeddedWebUI = yield* Effect.promise(() => embeddedUI()) const path = new URL(request.url, "http://localhost").pathname - if (embeddedWebUI) { - const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null - if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - - if (yield* services.fs.existsSafe(match)) { - const mime = AppFileSystem.mimeType(match) - const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP) - return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers }) - } - - return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) - } + if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI) const response = yield* services.client.execute( HttpClientRequest.make(request.method)(upstreamURL(path), { diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 8b7a6a1ac3..332ad16c64 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -15,7 +15,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { ServerAuth } from "../../src/server/auth" import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" -import { serveUIEffect } from "../../src/server/shared/ui" +import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui" import { Server } from "../../src/server/server" void Log.init({ print: false }) @@ -184,6 +184,39 @@ describe("HttpApi UI fallback", () => { expect(await response.text()).toBe("console.log('ok')") }) + test("serves embedded UI assets when Bun can read them but access reports missing", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + let readPath: string | undefined + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + return yield* serveEmbeddedUIEffect( + "/assets/app.js", + { + ...fs, + existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"), + readFile: (path) => { + readPath = path + return path === "/$bunfs/root/assets/app.js" + ? Effect.succeed(new TextEncoder().encode("console.log('embedded')")) + : Effect.die(`unexpected embedded UI path: ${path}`) + }, + }, + { "assets/app.js": "/$bunfs/root/assets/app.js" }, + ) + }).pipe( + Effect.provide(AppFileSystem.defaultLayer), + Effect.map(HttpServerResponse.toWeb), + ), + ) + + expect(response.status).toBe(200) + expect(readPath).toBe("/$bunfs/root/assets/app.js") + expect(response.headers.get("content-type")).toContain("text/javascript") + expect(await response.text()).toBe("console.log('embedded')") + }) + test("keeps matched API routes ahead of the UI fallback", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true From 755cd561ec9f6be6cb3de75790aa44501c6d385c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 3 May 2026 19:45:26 +0000 Subject: [PATCH 130/178] chore: generate --- packages/opencode/test/server/httpapi-ui.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 332ad16c64..f364491ace 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -205,10 +205,7 @@ describe("HttpApi UI fallback", () => { }, { "assets/app.js": "/$bunfs/root/assets/app.js" }, ) - }).pipe( - Effect.provide(AppFileSystem.defaultLayer), - Effect.map(HttpServerResponse.toWeb), - ), + }).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)), ) expect(response.status).toBe(200) From 825ab2e38d1f41074bb536b6ba5771f30594b197 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 16:41:10 -0400 Subject: [PATCH 131/178] refactor(cli): effectify provider commands (#25633) --- packages/opencode/src/cli/cmd/providers.ts | 276 +++++++++++---------- packages/opencode/src/cli/effect/prompt.ts | 24 +- 2 files changed, 158 insertions(+), 142 deletions(-) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 44fa420153..749139e2dc 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,9 +1,8 @@ import { Auth } from "../../auth" -import { AppRuntime } from "../../effect/app-runtime" import { cmd } from "./cmd" -import { effectCmd } from "../effect-cmd" -import * as prompts from "@clack/prompts" +import { CliError, effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" +import * as Prompt from "../effect/prompt" import { ModelsDev } from "@/provider/models" import { map, pipe, sortBy, values } from "remeda" @@ -14,44 +13,57 @@ import { Global } from "@opencode-ai/core/global" import { Plugin } from "../../plugin" import type { Hooks } from "@opencode-ai/plugin" import { Process } from "@/util/process" +import { errorMessage } from "@/util/error" import { text } from "node:stream/consumers" -import { Effect } from "effect" +import { Effect, Option } from "effect" type PluginAuth = NonNullable -const put = (key: string, info: Auth.Info) => - AppRuntime.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - yield* auth.set(key, info) - }), - ) - -async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise { - let index = 0 - if (methodName) { +const promptValue = (value: Option.Option) => { + if (Option.isNone(value)) return Effect.die(new UI.CancelledError()) + return Effect.succeed(value.value) +} + +const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) { + const auth = yield* Auth.Service + yield* Effect.orDie(auth.set(key, info)) +}) + +const cliTry = (message: string, fn: () => PromiseLike) => + Effect.tryPromise({ + try: fn, + catch: (error) => new CliError({ message: message + errorMessage(error) }), + }) + +const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* ( + plugin: { auth: PluginAuth }, + provider: string, + methodName?: string, +) { + const index = yield* Effect.gen(function* () { + if (!methodName) { + if (plugin.auth.methods.length <= 1) return 0 + return yield* promptValue( + yield* Prompt.select({ + message: "Login method", + options: plugin.auth.methods.map((x, index) => ({ + label: x.label, + value: index, + })), + }), + ) + } const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase()) if (match === -1) { - prompts.log.error( + return yield* fail( `Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`, ) - process.exit(1) } - index = match - } else if (plugin.auth.methods.length > 1) { - const method = await prompts.select({ - message: "Login method", - options: plugin.auth.methods.map((x, index) => ({ - label: x.label, - value: index.toString(), - })), - }) - if (prompts.isCancel(method)) throw new UI.CancelledError() - index = parseInt(method) - } + return match + }) const method = plugin.auth.methods[index] - await new Promise((r) => setTimeout(r, 10)) + yield* Effect.sleep("10 millis") const inputs: Record = {} if (method.prompts) { for (const prompt of method.prompts) { @@ -63,46 +75,44 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, } if (prompt.condition && !prompt.condition(inputs)) continue if (prompt.type === "select") { - const value = await prompts.select({ + const value = yield* Prompt.select({ message: prompt.message, options: prompt.options, }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value - } else { - const value = await prompts.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, - }) - if (prompts.isCancel(value)) throw new UI.CancelledError() - inputs[prompt.key] = value + inputs[prompt.key] = yield* promptValue(value) + continue } + const value = yield* Prompt.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined, + }) + inputs[prompt.key] = yield* promptValue(value) } } if (method.type === "oauth") { - const authorize = await method.authorize(inputs) + const authorize = yield* cliTry("Failed to authorize: ", () => method.authorize(inputs)) if (authorize.url) { - prompts.log.info("Go to: " + authorize.url) + yield* Prompt.log.info("Go to: " + authorize.url) } if (authorize.method === "auto") { if (authorize.instructions) { - prompts.log.info(authorize.instructions) + yield* Prompt.log.info(authorize.instructions) } - const spinner = prompts.spinner() - spinner.start("Waiting for authorization...") - const result = await authorize.callback() + const spinner = Prompt.spinner() + yield* spinner.start("Waiting for authorization...") + const result = yield* cliTry("Failed to authorize: ", () => authorize.callback()) if (result.type === "failed") { - spinner.stop("Failed to authorize", 1) + yield* spinner.stop("Failed to authorize", 1) } if (result.type === "success") { const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await put(saveProvider, { + yield* put(saveProvider, { type: "oauth", refresh, access, @@ -111,30 +121,30 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await put(saveProvider, { + yield* put(saveProvider, { type: "api", key: result.key, }) } - spinner.stop("Login successful") + yield* spinner.stop("Login successful") } } if (authorize.method === "code") { - const code = await prompts.text({ + const code = yield* Prompt.text({ message: "Paste the authorization code here: ", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(code)) throw new UI.CancelledError() - const result = await authorize.callback(code) + const authorizationCode = yield* promptValue(code) + const result = yield* cliTry("Failed to authorize: ", () => authorize.callback(authorizationCode)) if (result.type === "failed") { - prompts.log.error("Failed to authorize") + yield* Prompt.log.error("Failed to authorize") } if (result.type === "success") { const saveProvider = result.provider ?? provider if ("refresh" in result) { const { type: _, provider: __, refresh, access, expires, ...extraFields } = result - await put(saveProvider, { + yield* put(saveProvider, { type: "oauth", refresh, access, @@ -143,56 +153,57 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, }) } if ("key" in result) { - await put(saveProvider, { + yield* put(saveProvider, { type: "api", key: result.key, }) } - prompts.log.success("Login successful") + yield* Prompt.log.success("Login successful") } } - prompts.outro("Done") + yield* Prompt.outro("Done") return true } if (method.type === "api") { - const key = await prompts.password({ + const key = yield* Prompt.password({ message: "Enter your API key", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) - if (prompts.isCancel(key)) throw new UI.CancelledError() + const apiKey = yield* promptValue(key) const metadata = Object.keys(inputs).length ? { metadata: inputs } : {} - if (!method.authorize) { - await put(provider, { + const authorizeApi = method.authorize + if (!authorizeApi) { + yield* put(provider, { type: "api", - key, + key: apiKey, ...metadata, }) - prompts.outro("Done") + yield* Prompt.outro("Done") return true } - const result = await method.authorize(inputs) + const result = yield* cliTry("Failed to authorize: ", () => authorizeApi(inputs)) if (result.type === "failed") { - prompts.log.error("Failed to authorize") + yield* Prompt.log.error("Failed to authorize") } if (result.type === "success") { const saveProvider = result.provider ?? provider - await put(saveProvider, { + yield* put(saveProvider, { type: "api", - key: result.key ?? key, + key: result.key ?? apiKey, ...metadata, }) - prompts.log.success("Login successful") + yield* Prompt.log.success("Login successful") } - prompts.outro("Done") + yield* Prompt.outro("Done") return true } return false -} +}) export function resolvePluginProviders(input: { hooks: Hooks[] @@ -244,16 +255,16 @@ export const ProvidersListCommand = effectCmd({ const authPath = path.join(Global.Path.data, "auth.json") const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath - prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) + yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) const results = Object.entries(yield* Effect.orDie(authSvc.all())) const database = yield* modelsDev.get() for (const [providerID, result] of results) { const name = database[providerID]?.name || providerID - prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) + yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) } - prompts.outro(`${results.length} credentials`) + yield* Prompt.outro(`${results.length} credentials`) const activeEnvVars: Array<{ provider: string; envVar: string }> = [] @@ -270,13 +281,13 @@ export const ProvidersListCommand = effectCmd({ if (activeEnvVars.length > 0) { UI.empty() - prompts.intro("Environment") + yield* Prompt.intro("Environment") for (const { provider, envVar } of activeEnvVars) { - prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) + yield* Prompt.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`) } - prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) + yield* Prompt.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s")) } }), }) @@ -301,36 +312,42 @@ export const ProvidersLoginCommand = effectCmd({ type: "string", }), handler: Effect.fn("Cli.providers.login")(function* (args) { - const cfgSvc = yield* Config.Service - const pluginSvc = yield* Plugin.Service - const modelsDev = yield* ModelsDev.Service const authSvc = yield* Auth.Service UI.empty() - prompts.intro("Add credential") + yield* Prompt.intro("Add credential") if (args.url) { const url = args.url.replace(/\/+$/, "") - const wellknown = (yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`).then((x) => x.json()))) as { + const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () => + fetch(`${url}/.well-known/opencode`).then((x) => x.json()), + )) as { auth: { command: string[]; env: string } } - prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) - const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit" }) + yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) + const abort = new AbortController() + const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal }) if (!proc.stdout) { - prompts.log.error("Failed") - prompts.outro("Done") + yield* Prompt.log.error("Failed") + yield* Prompt.outro("Done") return } - const [exit, token] = yield* Effect.promise(() => Promise.all([proc.exited, text(proc.stdout!)])) + const [exit, token] = yield* cliTry("Failed to run auth provider command: ", () => + Promise.all([proc.exited, text(proc.stdout!)]), + ).pipe(Effect.ensuring(Effect.sync(() => abort.abort()))) if (exit !== 0) { - prompts.log.error("Failed") - prompts.outro("Done") + yield* Prompt.log.error("Failed") + yield* Prompt.outro("Done") return } yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() })) - prompts.log.success("Logged into " + url) - prompts.outro("Done") + yield* Prompt.log.success("Logged into " + url) + yield* Prompt.outro("Done") return } + + const cfgSvc = yield* Config.Service + const pluginSvc = yield* Plugin.Service + const modelsDev = yield* ModelsDev.Service yield* Effect.ignore(modelsDev.refresh(true)) const config = yield* cfgSvc.get() @@ -392,53 +409,46 @@ export const ProvidersLoginCommand = effectCmd({ const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) const match = byID ?? byName if (!match) { - prompts.log.error(`Unknown provider "${input}"`) - process.exit(1) + return yield* fail(`Unknown provider "${input}"`) } provider = match.value } else { - const selected = yield* Effect.promise(() => - prompts.autocomplete({ + provider = yield* promptValue( + yield* Prompt.autocomplete({ message: "Select provider", maxItems: 8, options: [...options, { value: "other", label: "Other" }], }), ) - if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError()) - provider = selected as string } const plugin = hooks.findLast((x) => x.auth?.provider === provider) if (plugin && plugin.auth) { - const handled = yield* Effect.promise(() => handlePluginAuth({ auth: plugin.auth! }, provider, args.method)) + const handled = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method) if (handled) return } if (provider === "other") { - const custom = yield* Effect.promise(() => - prompts.text({ + provider = (yield* promptValue( + yield* Prompt.text({ message: "Enter provider id", validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"), }), - ) - if (prompts.isCancel(custom)) yield* Effect.die(new UI.CancelledError()) - provider = (custom as string).replace(/^@ai-sdk\//, "") + )).replace(/^@ai-sdk\//, "") const customPlugin = hooks.findLast((x) => x.auth?.provider === provider) if (customPlugin && customPlugin.auth) { - const handled = yield* Effect.promise(() => - handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method), - ) + const handled = yield* handlePluginAuth({ auth: customPlugin.auth! }, provider, args.method) if (handled) return } - prompts.log.warn( + yield* Prompt.log.warn( `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, ) } if (provider === "amazon-bedrock") { - prompts.log.info( + yield* Prompt.log.info( "Amazon Bedrock authentication priority:\n" + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + @@ -448,29 +458,27 @@ export const ProvidersLoginCommand = effectCmd({ } if (provider === "opencode") { - prompts.log.info("Create an api key at https://opencode.ai/auth") + yield* Prompt.log.info("Create an api key at https://opencode.ai/auth") } if (provider === "vercel") { - prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") + yield* Prompt.log.info("You can create an api key at https://vercel.link/ai-gateway-token") } if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) { - prompts.log.info( + yield* Prompt.log.info( "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway", ) } - const key = yield* Effect.promise(() => - prompts.password({ - message: "Enter your API key", - validate: (x) => (x && x.length > 0 ? undefined : "Required"), - }), - ) - if (prompts.isCancel(key)) yield* Effect.die(new UI.CancelledError()) - yield* Effect.orDie(authSvc.set(provider, { type: "api", key: key as string })) + const key = yield* Prompt.password({ + message: "Enter your API key", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }) + const apiKey = yield* promptValue(key) + yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey })) - prompts.outro("Done") + yield* Prompt.outro("Done") }), }) @@ -485,24 +493,20 @@ export const ProvidersLogoutCommand = effectCmd({ UI.empty() const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) - prompts.intro("Remove credential") + yield* Prompt.intro("Remove credential") if (credentials.length === 0) { - prompts.log.error("No credentials found") + yield* Prompt.log.error("No credentials found") return } const database = yield* modelsDev.get() - const selected = yield* Effect.promise(() => - prompts.select({ - message: "Select provider", - options: credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })), - }), - ) - if (prompts.isCancel(selected)) yield* Effect.die(new UI.CancelledError()) - const providerID = selected as string - yield* Effect.orDie(authSvc.remove(providerID)) - prompts.outro("Logout successful") + const selected = yield* Prompt.select({ + message: "Select provider", + options: credentials.map(([key, value]) => ({ + label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", + value: key, + })), + }) + yield* Effect.orDie(authSvc.remove(yield* promptValue(selected))) + yield* Prompt.outro("Logout successful") }), }) diff --git a/packages/opencode/src/cli/effect/prompt.ts b/packages/opencode/src/cli/effect/prompt.ts index 7f9cd8cfe6..2713f1a5b8 100644 --- a/packages/opencode/src/cli/effect/prompt.ts +++ b/packages/opencode/src/cli/effect/prompt.ts @@ -6,15 +6,27 @@ export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg)) export const log = { info: (msg: string) => Effect.sync(() => prompts.log.info(msg)), + error: (msg: string) => Effect.sync(() => prompts.log.error(msg)), + warn: (msg: string) => Effect.sync(() => prompts.log.warn(msg)), + success: (msg: string) => Effect.sync(() => prompts.log.success(msg)), +} + +const optional = (result: Value | symbol) => { + if (prompts.isCancel(result)) return Option.none() + return Option.some(result) } export const select = (opts: Parameters>[0]) => - Effect.tryPromise(() => prompts.select(opts)).pipe( - Effect.map((result) => { - if (prompts.isCancel(result)) return Option.none() - return Option.some(result) - }), - ) + Effect.promise(() => prompts.select(opts)).pipe(Effect.map((result) => optional(result))) + +export const autocomplete = (opts: Parameters>[0]) => + Effect.promise(() => prompts.autocomplete(opts)).pipe(Effect.map((result) => optional(result))) + +export const text = (opts: Parameters[0]) => + Effect.promise(() => prompts.text(opts)).pipe(Effect.map((result) => optional(result))) + +export const password = (opts: Parameters[0]) => + Effect.promise(() => prompts.password(opts)).pipe(Effect.map((result) => optional(result))) export const spinner = () => { const s = prompts.spinner() From ca6150d6f092cc8761d6072b0b07b6a7de8748cf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 17:13:42 -0400 Subject: [PATCH 132/178] fix(app): preserve auth token credentials (#25636) --- packages/app/src/components/terminal.tsx | 11 +++- packages/app/src/context/server.test.ts | 53 +++++++++++++++++++ packages/app/src/context/server.tsx | 51 ++++++++++-------- packages/app/src/entry.tsx | 19 ++++++- packages/app/src/utils/server.test.ts | 23 ++++++++ packages/app/src/utils/server.ts | 18 ++++++- .../src/utils/terminal-websocket-url.test.ts | 18 ++++++- .../app/src/utils/terminal-websocket-url.ts | 10 +++- 8 files changed, 176 insertions(+), 27 deletions(-) create mode 100644 packages/app/src/context/server.test.ts create mode 100644 packages/app/src/utils/server.test.ts diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 998936bc68..d4212e32e9 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -503,7 +503,16 @@ export const Terminal = (props: TerminalProps) => { drop?.() const socket = new WebSocket( - terminalWebSocketURL({ url, id, directory, cursor: seek, sameOrigin, username, password }), + terminalWebSocketURL({ + url, + id, + directory, + cursor: seek, + sameOrigin, + username, + password, + authToken: server.current?.type === "http" ? server.current.authToken : false, + }), ) socket.binaryType = "arraybuffer" ws = socket diff --git a/packages/app/src/context/server.test.ts b/packages/app/src/context/server.test.ts new file mode 100644 index 0000000000..1fa35247c8 --- /dev/null +++ b/packages/app/src/context/server.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { resolveServerList, ServerConnection } from "./server" + +describe("resolveServerList", () => { + test("lets startup auth_token credentials override a persisted same-url server", () => { + const list = resolveServerList({ + stored: [{ url: "https://server.example.test" }], + props: [ + { + type: "http", + authToken: true, + http: { + url: "https://server.example.test", + username: "opencode", + password: "secret", + }, + }, + ], + }) + + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("http") + expect(list[0]?.http).toEqual({ + url: "https://server.example.test", + username: "opencode", + password: "secret", + }) + expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true) + expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test") + }) + + test("keeps persisted credentials when startup has no auth_token", () => { + const list = resolveServerList({ + stored: [ + { + url: "https://server.example.test", + username: "opencode", + password: "saved", + }, + ], + props: [{ type: "http", http: { url: "https://server.example.test" } }], + }) + + expect(list).toHaveLength(1) + expect(list[0]?.type).toBe("http") + expect(list[0]?.http).toEqual({ + url: "https://server.example.test", + username: "opencode", + password: "saved", + }) + expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined() + }) +}) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 1204fba557..a981d99fa1 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -33,6 +33,33 @@ function isLocalHost(url: string) { if (host === "localhost" || host === "127.0.0.1") return "local" } +export function resolveServerList(input: { + props?: Array + stored: StoredServer[] +}): Array { + const servers = [ + ...input.stored.map((value) => + typeof value === "string" + ? { + type: "http" as const, + http: { url: value }, + } + : value, + ), + ...(input.props ?? []), + ] + + const deduped = new Map() + for (const value of servers) { + const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value } + const key = ServerConnection.key(conn) + if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue + deduped.set(key, conn) + } + + return [...deduped.values()] +} + export namespace ServerConnection { type Base = { displayName?: string } @@ -46,6 +73,7 @@ export namespace ServerConnection { export type Http = { type: "http" http: HttpBase + authToken?: boolean } & Base export type Sidecar = { @@ -113,26 +141,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url) const allServers = createMemo((): Array => { - const servers = [ - ...(props.servers ?? []), - ...store.list.map((value) => - typeof value === "string" - ? { - type: "http" as const, - http: { url: value }, - } - : value, - ), - ] - - const deduped = new Map( - servers.map((value) => { - const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value } - return [ServerConnection.key(conn), conn] - }), - ) - - return [...deduped.values()] + return resolveServerList({ stored: store.list, props: props.servers }) }) const [state, setState] = createStore({ @@ -174,7 +183,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( function add(input: ServerConnection.Http) { const url_ = normalizeServerUrl(input.http.url) if (!url_) return - const conn = { ...input, http: { ...input.http, url: url_ } } + const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } } return batch(() => { const existing = store.list.findIndex((x) => url(x) === url_) if (existing !== -1) { diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index ade572c2fd..5115f0348a 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -7,6 +7,7 @@ import { type Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" import { handleNotificationClick } from "@/utils/notification-click" +import { authFromToken } from "@/utils/server" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -111,6 +112,13 @@ const getDefaultUrl = () => { return getCurrentUrl() } +const clearAuthToken = () => { + const params = new URLSearchParams(location.search) + if (!params.has("auth_token")) return + params.delete("auth_token") + history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash) +} + const platform: Platform = { platform: "web", version: pkg.version, @@ -146,7 +154,16 @@ if (import.meta.env.VITE_SENTRY_DSN) { } if (root instanceof HTMLElement) { - const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } } + const auth = authFromToken(new URLSearchParams(location.search).get("auth_token")) + clearAuthToken() + const server: ServerConnection.Http = { + type: "http", + authToken: !!auth, + http: { + url: getCurrentUrl(), + ...auth, + }, + } render( () => ( diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts new file mode 100644 index 0000000000..4666b7d6d0 --- /dev/null +++ b/packages/app/src/utils/server.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { authFromToken, authTokenFromCredentials } from "./server" + +describe("authFromToken", () => { + test("decodes basic auth credentials from auth_token", () => { + expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" }) + }) + + test("defaults blank username to opencode", () => { + expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" }) + }) + + test("ignores malformed tokens", () => { + expect(authFromToken("not base64")).toBeUndefined() + expect(authFromToken(btoa("missing-separator"))).toBeUndefined() + }) +}) + +describe("authTokenFromCredentials", () => { + test("encodes credentials with the default username", () => { + expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret")) + }) +}) diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index ae849b71ee..603784e4d4 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,5 +1,21 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { ServerConnection } from "@/context/server" +import { decode64 } from "@/utils/base64" + +export function authTokenFromCredentials(input: { username?: string; password: string }) { + return btoa(`${input.username ?? "opencode"}:${input.password}`) +} + +export function authFromToken(token: string | null) { + const decoded = decode64(token ?? undefined) + if (!decoded) return + const separator = decoded.indexOf(":") + if (separator === -1) return + return { + username: decoded.slice(0, separator) || "opencode", + password: decoded.slice(separator + 1), + } +} export function createSdkForServer({ server, @@ -10,7 +26,7 @@ export function createSdkForServer({ const auth = (() => { if (!server.password) return return { - Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`, + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, } })() diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts index c85863abd7..5fa1506b1e 100644 --- a/packages/app/src/utils/terminal-websocket-url.test.ts +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -19,7 +19,7 @@ describe("terminalWebSocketURL", () => { expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) }) - test("omits query auth for same-origin websocket URL", () => { + test("omits query auth for same-origin saved credentials", () => { const url = terminalWebSocketURL({ url: "https://app.example.test", id: "pty_test", @@ -33,4 +33,20 @@ describe("terminalWebSocketURL", () => { expect(url.protocol).toBe("wss:") expect(url.searchParams.has("auth_token")).toBe(false) }) + + test("uses query auth for same-origin credentials from auth_token", () => { + const url = terminalWebSocketURL({ + url: "https://app.example.test", + id: "pty_test", + directory: "/tmp/project", + cursor: 10, + sameOrigin: true, + username: "opencode", + password: "secret", + authToken: true, + }) + + expect(url.protocol).toBe("wss:") + expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) + }) }) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index d364762d7e..c1c7abad4a 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -1,3 +1,5 @@ +import { authTokenFromCredentials } from "@/utils/server" + export function terminalWebSocketURL(input: { url: string id: string @@ -6,12 +8,16 @@ export function terminalWebSocketURL(input: { sameOrigin: boolean username: string password?: string + authToken?: boolean }) { const next = new URL(`${input.url}/pty/${input.id}/connect`) next.searchParams.set("directory", input.directory) next.searchParams.set("cursor", String(input.cursor)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - if (!input.sameOrigin && input.password) - next.searchParams.set("auth_token", btoa(`${input.username}:${input.password}`)) + if (input.password && (!input.sameOrigin || input.authToken)) + next.searchParams.set( + "auth_token", + authTokenFromCredentials({ username: input.username, password: input.password }), + ) return next } From c2b1974dddd51a08f2e995743aa9d377e0046fdf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 18:07:10 -0400 Subject: [PATCH 133/178] Effectify plugin agent regression test (#25646) --- .../agent/plugin-agent-regression.test.ts | 105 ++++++++++-------- 1 file changed, 59 insertions(+), 46 deletions(-) diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 72e538aa3a..3ac923c435 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,52 +1,65 @@ -import { afterEach, expect, test } from "bun:test" +import { expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Layer } from "effect" import path from "path" import { pathToFileURL } from "url" -import { AppRuntime } from "../../src/effect/app-runtime" import { Agent } from "../../src/agent/agent" -import { Instance } from "../../src/project/instance" -import { WithInstance } from "../../src/project/with-instance" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { InstanceRef } from "../../src/effect/instance-ref" +import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceStore } from "../../src/project/instance-store" +import { tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" -afterEach(async () => { - await disposeAllInstances() -}) +const pluginAgent = { + name: "plugin_added", + description: "Added by a plugin via the config hook", + mode: "subagent", +} as const -test("plugin-registered agents appear in Agent.list", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const pluginFile = path.join(dir, "plugin.ts") - await Bun.write( - pluginFile, - [ - "export default async () => ({", - " config: async (cfg) => {", - " cfg.agent = cfg.agent ?? {}", - " cfg.agent.plugin_added = {", - ' description: "Added by a plugin via the config hook",', - ' mode: "subagent",', - " }", - " },", - "})", - "", - ].join("\n"), - ) - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(pluginFile).href], - }), - ) - }, - }) +const it = testEffect(Layer.mergeAll(Agent.defaultLayer, InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) - await WithInstance.provide({ - directory: tmp.path, - fn: async () => { - const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list())) - const added = agents.find((agent) => agent.name === "plugin_added") - expect(added?.description).toBe("Added by a plugin via the config hook") - expect(added?.mode).toBe("subagent") - }, - }) -}) +it.live("plugin-registered agents appear in Agent.list", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const pluginFile = path.join(dir, "plugin.ts") + + yield* Effect.promise(async () => { + await Promise.all([ + Bun.write( + pluginFile, + [ + "export default async () => ({", + " config: async (cfg) => {", + " cfg.agent = cfg.agent ?? {}", + ` cfg.agent[${JSON.stringify(pluginAgent.name)}] = {`, + ` description: ${JSON.stringify(pluginAgent.description)},`, + ` mode: ${JSON.stringify(pluginAgent.mode)},`, + " }", + " },", + "})", + "", + ].join("\n"), + ), + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(pluginFile).href], + }), + ), + ]) + }) + + const agents = yield* InstanceStore.Service.use((store) => + Effect.gen(function* () { + const ctx = yield* store.load({ directory: dir }) + yield* Effect.addFinalizer(() => store.dispose(ctx).pipe(Effect.ignore)) + return yield* Agent.Service.use((svc) => svc.list()).pipe(Effect.provideService(InstanceRef, ctx)) + }), + ) + const added = agents.find((agent) => agent.name === pluginAgent.name) + + expect(added?.description).toBe(pluginAgent.description) + expect(added?.mode).toBe(pluginAgent.mode) + }), +) From ce89bcb8e238401ea8fee000dc54539057d47dc4 Mon Sep 17 00:00:00 2001 From: Utkub24 <76127062+Utkub24@users.noreply.github.com> Date: Mon, 4 May 2026 01:58:16 +0300 Subject: [PATCH 134/178] fix: allow Codex Spark with Codex OAuth (#25640) --- packages/opencode/src/plugin/codex.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index a97f3e9e8d..d520750035 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -14,7 +14,14 @@ const ISSUER = "https://auth.openai.com" const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" const OAUTH_PORT = 1455 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000 -const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"]) +const ALLOWED_MODELS = new Set([ + "gpt-5.5", + "gpt-5.2", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.4", + "gpt-5.4-mini", +]) interface PkceCodes { verifier: string From 7bc26dafae09d326a0f66d2b69b379bc19b3b26e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 3 May 2026 22:56:14 -0400 Subject: [PATCH 135/178] feat(server): pty websocket auth tickets (#25660) --- packages/app/src/components/terminal.tsx | 25 +++- .../app/src/utils/terminal-websocket-url.ts | 9 +- packages/opencode/src/effect/app-runtime.ts | 2 + packages/opencode/src/pty/ticket.ts | 66 +++++++++ packages/opencode/src/server/cors.ts | 20 +++ packages/opencode/src/server/error.ts | 3 + packages/opencode/src/server/middleware.ts | 3 + .../routes/instance/httpapi/groups/pty.ts | 15 +- .../routes/instance/httpapi/handlers/pty.ts | 34 ++++- .../httpapi/middleware/authorization.ts | 11 +- .../server/routes/instance/httpapi/server.ts | 5 +- .../src/server/routes/instance/index.ts | 8 +- .../src/server/routes/instance/pty.ts | 86 ++++++++++-- packages/opencode/src/server/server.ts | 4 +- .../opencode/src/server/shared/pty-ticket.ts | 15 ++ packages/opencode/test/pty/ticket.test.ts | 59 ++++++++ .../test/server/httpapi-listen.test.ts | 131 +++++++++++++++++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 34 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 45 ++++++ 19 files changed, 545 insertions(+), 30 deletions(-) create mode 100644 packages/opencode/src/pty/ticket.ts create mode 100644 packages/opencode/src/server/shared/pty-ticket.ts create mode 100644 packages/opencode/test/pty/ticket.test.ts diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index d4212e32e9..7bcc02d62d 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -479,6 +479,21 @@ export const Terminal = (props: TerminalProps) => { return false }) + const connectToken = async () => { + const result = await client.pty.connectToken( + { ptyID: id }, + { + throwOnError: false, + headers: { "x-opencode-ticket": "1" }, + }, + ) + if (result.response.status === 200 && result.data?.ticket) return result.data.ticket + if ((result.response.status === 404 || result.response.status === 405) && password) return + if (result.response.status === 403) + throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") + throw new Error(`PTY connect ticket failed with ${result.response.status}`) + } + const retry = (err: unknown) => { if (disposed) return if (reconn !== undefined) return @@ -498,16 +513,24 @@ export const Terminal = (props: TerminalProps) => { }, ms) } - const open = () => { + const open = async () => { if (disposed) return drop?.() + const ticket = await connectToken().catch((err) => { + fail(err) + return undefined + }) + if (once.value) return + if (disposed) return + const socket = new WebSocket( terminalWebSocketURL({ url, id, directory, cursor: seek, + ticket, sameOrigin, username, password, diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index c1c7abad4a..06facdc7d2 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -5,8 +5,9 @@ export function terminalWebSocketURL(input: { id: string directory: string cursor: number - sameOrigin: boolean - username: string + ticket?: string + sameOrigin?: boolean + username?: string password?: string authToken?: boolean }) { @@ -14,6 +15,10 @@ export function terminalWebSocketURL(input: { next.searchParams.set("directory", input.directory) next.searchParams.set("cursor", String(input.cursor)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" + if (input.ticket) { + next.searchParams.set("ticket", input.ticket) + return next + } if (input.password && (!input.sameOrigin || input.authToken)) next.searchParams.set( "auth_token", diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index e8c8025ea3..76ed26d302 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -46,6 +46,7 @@ import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" import { Worktree } from "@/worktree" import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { Installation } from "@/installation" import { ShareNext } from "@/share/share-next" import { SessionShare } from "@/share/session" @@ -98,6 +99,7 @@ export const AppLayer = Layer.mergeAll( Workspace.defaultLayer, Worktree.appLayer, Pty.defaultLayer, + PtyTicket.defaultLayer, Installation.defaultLayer, ShareNext.defaultLayer, SessionShare.defaultLayer, diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts new file mode 100644 index 0000000000..d40301cad2 --- /dev/null +++ b/packages/opencode/src/pty/ticket.ts @@ -0,0 +1,66 @@ +export * as PtyTicket from "./ticket" + +import { WorkspaceID } from "@/control-plane/schema" +import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" +import { PtyID } from "@/pty/schema" +import { PositiveInt } from "@/util/schema" +import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" + +const DEFAULT_TTL = Duration.seconds(60) +const CAPACITY = 10_000 + +export const ConnectToken = Schema.Struct({ + ticket: Schema.String, + expires_in: PositiveInt, +}) + +export type Scope = { + readonly ptyID: PtyID + readonly directory?: string + readonly workspaceID?: WorkspaceID +} + +export interface Interface { + issue(input: Scope): Effect.Effect + consume(input: Scope & { readonly ticket: string }): Effect.Effect +} + +export class Service extends Context.Service()("@opencode/PtyTicket") {} + +function matches(record: Scope, input: Scope) { + return record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID +} + +// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is +// never invoked; it dies if it ever is, which would signal a misuse of the Service interface. +const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get") + +// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL. +export const make = (ttl: Duration.Input = DEFAULT_TTL) => + Effect.gen(function* () { + const cache = yield* Cache.make({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl }) + const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl)))) + return Service.of({ + issue: Effect.fn("PtyTicket.issue")(function* (input) { + const ticket = crypto.randomUUID() + yield* Cache.set(cache, ticket, input) + return { ticket, expires_in: expiresIn } + }), + consume: Effect.fn("PtyTicket.consume")(function* (input) { + return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input)) + }), + }) + }) + +export const layer = Layer.effect(Service, make()) + +export const defaultLayer = layer + +export const scope = Effect.gen(function* () { + const instance = yield* InstanceRef + const workspaceID = yield* WorkspaceRef + return { + directory: instance?.directory, + workspaceID, + } +}) diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts index 62a181af3a..92296a3b7d 100644 --- a/packages/opencode/src/server/cors.ts +++ b/packages/opencode/src/server/cors.ts @@ -1,7 +1,13 @@ +import { Context } from "effect" + const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ export type CorsOptions = { readonly cors?: ReadonlyArray } +export const CorsConfig = Context.Reference("@opencode/ServerCorsConfig", { + defaultValue: () => undefined, +}) + export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) { if (!input) return true if (input.startsWith("http://localhost:")) return true @@ -12,3 +18,17 @@ export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOption if (opencodeOrigin.test(input)) return true return opts?.cors?.includes(input) ?? false } + +export function isAllowedRequestOrigin(input: string | undefined, host: string | undefined, opts?: CorsOptions) { + if (!input) return true + if (host && sameHost(input, host)) return true + return isAllowedCorsOrigin(input, opts) +} + +function sameHost(origin: string, host: string) { + try { + return new URL(origin).host === host + } catch { + return false + } +} diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts index 7c5861d919..506e798187 100644 --- a/packages/opencode/src/server/error.ts +++ b/packages/opencode/src/server/error.ts @@ -21,6 +21,9 @@ export const ERRORS = { }, }, }, + 403: { + description: "Forbidden", + }, 404: { description: "Not found", content: { diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index d2cc9b538d..898acaf089 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -12,6 +12,7 @@ import { cors } from "hono/cors" import { compress } from "hono/compress" import * as ServerBackend from "./backend" import { isAllowedCorsOrigin, type CorsOptions } from "./cors" +import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket" const log = Log.create({ service: "server" }) @@ -44,6 +45,7 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => { if (c.req.method === "OPTIONS") return next() const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() + if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next() const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`) @@ -58,6 +60,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M const attributes = { method: c.req.method, path: c.req.path, + // If this logger grows full-URL fields, redact auth_token and ticket query params. ...backendAttributes, } log.info("request", attributes) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts index d54bda4a84..3304ab9fbf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts @@ -1,4 +1,5 @@ import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { PtyID } from "@/pty/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" @@ -23,6 +24,7 @@ export const PtyPaths = { get: `${root}/:ptyID`, update: `${root}/:ptyID`, remove: `${root}/:ptyID`, + connectToken: `${root}/:ptyID/connect-token`, connect: `${root}/:ptyID/connect`, } as const @@ -93,6 +95,17 @@ export const PtyApi = HttpApi.make("pty") description: "Remove and terminate a specific pseudo-terminal (PTY) session.", }), ), + HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, { + params: { ptyID: PtyID }, + success: described(PtyTicket.ConnectToken, "WebSocket connect token"), + error: [HttpApiError.Forbidden, HttpApiError.NotFound], + }).annotateMerge( + OpenApi.annotations({ + identifier: "pty.connectToken", + summary: "Create PTY WebSocket token", + description: "Create a short-lived ticket for opening a PTY WebSocket connection.", + }), + ), ) .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." })) .middleware(InstanceContextMiddleware) @@ -113,7 +126,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add( HttpApiEndpoint.get("connect", PtyPaths.connect, { params: Params, success: described(Schema.Boolean, "Connected session"), - error: HttpApiError.NotFound, + error: [HttpApiError.Forbidden, HttpApiError.NotFound], }).annotateMerge( OpenApi.annotations({ identifier: "pty.connect", diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 2e2c4ee1cb..e5ff300a2a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -1,8 +1,15 @@ import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { PtyTicket } from "@/pty/ticket" import { handlePtyInput } from "@/pty/input" import { Shell } from "@/shell/shell" import { EffectBridge } from "@/effect/bridge" +import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@/server/shared/pty-ticket" import { Effect } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" @@ -11,9 +18,15 @@ import { InstanceHttpApi } from "../api" import { CursorQuery, Params, PtyPaths } from "../groups/pty" import { WebSocketTracker } from "../websocket-tracker" +function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) { + return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts) +} + export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => Effect.gen(function* () { const pty = yield* Pty.Service + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig const shells = Effect.fn("PtyHttpApi.shells")(function* () { return yield* Effect.promise(() => Shell.list()) @@ -54,6 +67,14 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler return true }) + const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) { + const request = yield* HttpServerRequest.HttpServerRequest + if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors)) + return yield* new HttpApiError.Forbidden({}) + if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({}) + return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) }) + }) + return handlers .handle("shells", shells) .handle("list", list) @@ -61,12 +82,15 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("get", get) .handle("update", update) .handle("remove", remove) + .handle("connectToken", connectToken) }), ) export const ptyConnectRoute = HttpRouter.use((router) => Effect.gen(function* () { const pty = yield* Pty.Service + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig yield* router.add( "GET", PtyPaths.connect, @@ -75,12 +99,20 @@ export const ptyConnectRoute = HttpRouter.use((router) => if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 }) const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery) + const request = yield* HttpServerRequest.HttpServerRequest + const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + const valid = validOrigin(request, cors) + ? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) }) + : false + if (!valid) return HttpServerResponse.empty({ status: 403 }) + } const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor) const cursor = parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1 ? parsedCursor : undefined - const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade) + const socket = yield* Effect.orDie(request.upgrade) const write = yield* socket.writer const closeAccepted = (event: Socket.CloseEvent) => socket diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 2a8f1cf4d4..6c6d0cd1f1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -2,6 +2,7 @@ import { ServerAuth } from "@/server/auth" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" +import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 @@ -55,7 +56,11 @@ function decodeCredential(input: string) { } function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) { - const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY) + return credentialFromURL(new URL(request.url, "http://localhost"), request) +} + +function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerRequest) { + const token = url.searchParams.get(AUTH_TOKEN_QUERY) if (token) return decodeCredential(token) const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "") if (match) return decodeCredential(match[1]) @@ -86,7 +91,9 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( return (effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest - return yield* credentialFromRequest(request).pipe( + const url = new URL(request.url, "http://localhost") + if (hasPtyConnectTicketURL(url)) return yield* effect + return yield* credentialFromURL(url, request).pipe( Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), ) }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 2944ced695..a3754c2e19 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -25,6 +25,7 @@ import { ProviderAuth } from "@/provider/auth" import { ModelsDev } from "@/provider/models" import { Provider } from "@/provider/provider" import { Pty } from "@/pty" +import { PtyTicket } from "@/pty/ticket" import { Question } from "@/question" import { Session } from "@/session/session" import { SessionCompaction } from "@/session/compaction" @@ -44,7 +45,7 @@ import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" -import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" +import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" @@ -163,6 +164,7 @@ export function createRoutes(corsOptions?: CorsOptions) { ProviderAuth.defaultLayer, Provider.defaultLayer, Pty.defaultLayer, + PtyTicket.defaultLayer, Question.defaultLayer, Ripgrep.defaultLayer, Session.defaultLayer, @@ -187,6 +189,7 @@ export function createRoutes(corsOptions?: CorsOptions) { FetchHttpClient.layer, HttpServer.layerServices, ]), + Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)), Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer), ) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 3f9f3f6607..89b5641e58 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -39,10 +39,11 @@ import { SessionPaths } from "./httpapi/groups/session" import { SyncPaths } from "./httpapi/groups/sync" import { TuiPaths } from "./httpapi/groups/tui" import { WorkspacePaths } from "./httpapi/groups/workspace" +import type { CorsOptions } from "@/server/cors" -export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { +export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => { const app = new Hono() - const handler = ExperimentalHttpApiServer.webHandler().handler + const handler = ExperimentalHttpApiServer.webHandler(opts).handler const context = Context.empty() as Context.Context app.all("/api/*", (c) => handler(c.req.raw, context)) @@ -107,6 +108,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { app.get(PtyPaths.get, (c) => handler(c.req.raw, context)) app.put(PtyPaths.update, (c) => handler(c.req.raw, context)) app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context)) + app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context)) app.get(PtyPaths.connect, (c) => handler(c.req.raw, context)) app.get(SessionPaths.list, (c) => handler(c.req.raw, context)) app.get(SessionPaths.status, (c) => handler(c.req.raw, context)) @@ -158,7 +160,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { return app .route("/project", ProjectRoutes()) - .route("/pty", PtyRoutes(upgrade)) + .route("/pty", PtyRoutes(upgrade, opts)) .route("/config", ConfigRoutes()) .route("/experimental", ExperimentalRoutes()) .route("/session", SessionRoutes()) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts index bff0b71915..fb8d5e356d 100644 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ b/packages/opencode/src/server/routes/instance/pty.ts @@ -1,4 +1,5 @@ import { Hono } from "hono" +import type { Context } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import type { UpgradeWebSocket } from "hono/ws" import { Effect, Schema } from "effect" @@ -6,10 +7,19 @@ import z from "zod" import { AppRuntime } from "@/effect/app-runtime" import { Pty } from "@/pty" import { PtyID } from "@/pty/schema" +import { PtyTicket } from "@/pty/ticket" import { Shell } from "@/shell/shell" import { NotFoundError } from "@/storage/storage" import { errors } from "../../error" import { jsonRequest, runRequest } from "./trace" +import { HTTPException } from "hono/http-exception" +import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" +import { + PTY_CONNECT_TICKET_QUERY, + PTY_CONNECT_TOKEN_HEADER, + PTY_CONNECT_TOKEN_HEADER_VALUE, +} from "@/server/shared/pty-ticket" +import { zod as effectZod } from "@/util/effect-zod" const ShellItem = z.object({ path: z.string(), @@ -18,7 +28,11 @@ const ShellItem = z.object({ }) const decodePtyID = Schema.decodeUnknownSync(PtyID) -export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { +function validOrigin(c: Context, opts?: CorsOptions) { + return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts) +} + +export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) { return new Hono() .get( "/shells", @@ -175,6 +189,43 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { return true }), ) + .post( + "/:ptyID/connect-token", + describeRoute({ + summary: "Create PTY WebSocket token", + description: "Create a short-lived token for opening a PTY WebSocket connection.", + operationId: "pty.connectToken", + responses: { + 200: { + description: "WebSocket connect token", + content: { + "application/json": { + schema: resolver(effectZod(PtyTicket.ConnectToken)), + }, + }, + }, + ...errors(403, 404), + }, + }), + validator("param", z.object({ ptyID: PtyID.zod })), + async (c) => { + if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts)) + throw new HTTPException(403) + const result = await runRequest( + "PtyRoutes.connectToken", + c, + Effect.gen(function* () { + const pty = yield* Pty.Service + const id = c.req.valid("param").ptyID + if (!(yield* pty.get(id))) return + const tickets = yield* PtyTicket.Service + return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!result) throw new NotFoundError({ message: "Session not found" }) + return c.json(result) + }, + ) .get( "/:ptyID/connect", describeRoute({ @@ -190,7 +241,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }, }, }, - ...errors(404), + ...errors(403, 404), }, }), validator("param", z.object({ ptyID: PtyID.zod })), @@ -201,14 +252,6 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { } const id = decodePtyID(c.req.param("ptyID")) - const cursor = (() => { - const value = c.req.query("cursor") - if (!value) return - const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed < -1) return - return parsed - })() - let handler: Handler | undefined if ( !(await runRequest( "PtyRoutes.connect", @@ -219,8 +262,29 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) { }), )) ) { - throw new Error("Session not found") + throw new NotFoundError({ message: "Session not found" }) + } + const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + if (!validOrigin(c, opts)) throw new HTTPException(403) + const valid = await runRequest( + "PtyRoutes.connect.ticket", + c, + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) }) + }), + ) + if (!valid) throw new HTTPException(403) } + const cursor = (() => { + const value = c.req.query("cursor") + if (!value) return + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < -1) return + return parsed + })() + let handler: Handler | undefined type Socket = { readyState: number diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 6c7a6743db..3971214f3d 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -120,7 +120,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv app: app .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) .use(FenceMiddleware) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)), + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)), runtime, } } @@ -136,7 +136,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv app: app .route("/", ControlPlaneRoutes()) .route("/", workspaceApp) - .route("/", InstanceRoutes(runtime.upgradeWebSocket)) + .route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)) .route("/", UIRoutes()), runtime, } diff --git a/packages/opencode/src/server/shared/pty-ticket.ts b/packages/opencode/src/server/shared/pty-ticket.ts new file mode 100644 index 0000000000..0efd06e6a7 --- /dev/null +++ b/packages/opencode/src/server/shared/pty-ticket.ts @@ -0,0 +1,15 @@ +export const PTY_CONNECT_TICKET_QUERY = "ticket" +export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket" +export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" + +const PTY_CONNECT_PATH = /^\/pty\/[^/]+\/connect$/ + +// Auth middleware skips Basic Auth when this matches; the PTY connect handler +// is then responsible for validating the ticket. +export function isPtyConnectPath(pathname: string) { + return PTY_CONNECT_PATH.test(pathname) +} + +export function hasPtyConnectTicketURL(url: URL) { + return isPtyConnectPath(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) +} diff --git a/packages/opencode/test/pty/ticket.test.ts b/packages/opencode/test/pty/ticket.test.ts new file mode 100644 index 0000000000..1b7d6005bf --- /dev/null +++ b/packages/opencode/test/pty/ticket.test.ts @@ -0,0 +1,59 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { WorkspaceID } from "../../src/control-plane/schema" +import { PtyID } from "../../src/pty/schema" +import { PtyTicket } from "../../src/pty/ticket" +import { testEffect } from "../lib/effect" + +const it = testEffect(PtyTicket.layer) +const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5))) + +describe("PTY websocket tickets", () => { + it.live("consumes tickets once", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" } + const issued = yield* tickets.issue(scope) + + expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true) + expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(false) + }), + ) + + it.live("rejects tickets scoped to a different request", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" }) + + expect( + yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket }), + ).toBe(false) + expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true) + }), + ) + + itExpiring.live("rejects tickets after the TTL elapses", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const issued = yield* tickets.issue({ ptyID }) + + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25))) + + expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(false) + }), + ) + + it.live("rejects tickets scoped to a different workspace", () => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const ptyID = PtyID.ascending() + const workspaceID = WorkspaceID.ascending() + const issued = yield* tickets.issue({ ptyID, workspaceID }) + + expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceID.ascending(), ticket: issued.ticket })).toBe(false) + expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true) + }), + ) +}) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 3ee57dc108..af4c0a01ce 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -31,8 +31,8 @@ afterEach(async () => { await resetDatabase() }) -async function startListener() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true +async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" Flag.OPENCODE_SERVER_PASSWORD = auth.password Flag.OPENCODE_SERVER_USERNAME = auth.username process.env.OPENCODE_SERVER_PASSWORD = auth.password @@ -40,19 +40,53 @@ async function startListener() { return Server.listen({ hostname: "127.0.0.1", port: 0 }) } +async function startNoAuthListener() { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false + Flag.OPENCODE_SERVER_PASSWORD = undefined + Flag.OPENCODE_SERVER_USERNAME = auth.username + delete process.env.OPENCODE_SERVER_PASSWORD + process.env.OPENCODE_SERVER_USERNAME = auth.username + return Server.listen({ hostname: "127.0.0.1", port: 0 }) +} + function authorization() { return `Basic ${btoa(`${auth.username}:${auth.password}`)}` } -function socketURL(listener: Awaited>, id: string, dir: string) { +function socketURL(listener: Awaited>, id: string, dir: string, ticket?: string) { const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url) url.protocol = "ws:" url.searchParams.set("directory", dir) url.searchParams.set("cursor", "-1") - url.searchParams.set("auth_token", btoa(`${auth.username}:${auth.password}`)) + if (ticket) url.searchParams.set("ticket", ticket) return url } +async function requestTicket( + listener: Awaited>, + id: string, + dir: string, + options?: { ticketHeader?: boolean; origin?: string }, +) { + const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), { + method: "POST", + headers: { + authorization: authorization(), + "x-opencode-directory": dir, + ...(options?.ticketHeader === false ? {} : { "x-opencode-ticket": "1" }), + ...(options?.origin ? { origin: options.origin } : {}), + }, + }) + + return response +} + +async function connectTicket(listener: Awaited>, id: string, dir: string) { + const response = await requestTicket(listener, id, dir) + expect(response.status).toBe(200) + return (await response.json()) as { ticket: string; expires_in: number } +} + async function createCat(listener: Awaited>, dir: string) { const response = await fetch(new URL(PtyPaths.create, listener.url), { method: "POST", @@ -81,6 +115,28 @@ async function openSocket(url: URL) { return ws } +async function expectSocketRejected(url: URL, init?: { headers?: Record }) { + // Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that. + const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record }) => WebSocket + const ws = new Ctor(url, init) + await withTimeout( + new Promise((resolve, reject) => { + ws.addEventListener( + "open", + () => { + ws.close(1000) + reject(new Error("websocket opened")) + }, + { once: true }, + ) + ws.addEventListener("error", () => resolve(), { once: true }) + ws.addEventListener("close", () => resolve(), { once: true }) + }), + 5_000, + "timed out waiting for websocket rejection", + ) +} + function stop(listener: Awaited>, label: string) { return withTimeout(listener.stop(true), 10_000, label) } @@ -125,7 +181,9 @@ describe("HttpApi Server.listen", () => { ) const info = await createCat(listener, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const ticket = await connectTicket(listener, info.id, tmp.path) + expect(ticket.expires_in).toBeGreaterThan(0) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) const closed = new Promise((resolve) => ws.addEventListener("close", () => resolve(), { once: true })) const message = waitForMessage(ws, (message) => message.includes("ping-listen")) @@ -140,7 +198,8 @@ describe("HttpApi Server.listen", () => { const restarted = await startListener() try { const nextInfo = await createCat(restarted, tmp.path) - const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path)) + const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path) + const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket)) const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted")) nextWs.send("ping-restarted\n") expect(await nextMessage).toContain("ping-restarted") @@ -152,4 +211,64 @@ describe("HttpApi Server.listen", () => { if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined) } }) + + testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener("hono") + try { + const info = await createCat(listener, tmp.path) + const ticket = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket)) + const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket")) + ws.send("ping-hono-ticket\n") + expect(await message).toContain("ping-hono-ticket") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up hono listener").catch(() => undefined) + } + }) + + testPty("rejects unsafe PTY ticket mint and connect requests", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const info = await createCat(listener, tmp.path) + + expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403) + expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403) + + await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket")) + + const reusable = await connectTicket(listener, info.id, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket)) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket)) + ws.close(1000) + + const other = await createCat(listener, tmp.path) + const scoped = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket)) + + const crossOrigin = await connectTicket(listener, info.id, tmp.path) + await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), { + headers: { origin: "https://evil.example" }, + }) + } finally { + await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined) + } + }) + + testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener() + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) + ws.send("ping-no-auth\n") + expect(await message).toContain("ping-no-auth") + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 74c5844626..e94132c2b2 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -99,6 +99,8 @@ import type { ProviderOauthCallbackResponses, PtyConnectErrors, PtyConnectResponses, + PtyConnectTokenErrors, + PtyConnectTokenResponses, PtyCreateErrors, PtyCreateResponses, PtyGetErrors, @@ -2345,6 +2347,38 @@ export class Pty extends HeyApiClient { }) } + /** + * Create PTY WebSocket token + * + * Create a short-lived ticket for opening a PTY WebSocket connection. + */ + public connectToken( + parameters: { + ptyID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/pty/{ptyID}/connect-token", + ...options, + ...params, + }) + } + /** * Connect to PTY session * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 79ef42d9e1..86c5a762b1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1563,6 +1563,10 @@ export type McpUnsupportedOAuthError = { error: string } +export type EffectHttpApiErrorForbidden = { + _tag: "Forbidden" +} + export type ProviderAuthMethod = { type: "oauth" | "api" label: string @@ -4671,6 +4675,43 @@ export type PtyUpdateResponses = { export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses] +export type PtyConnectTokenData = { + body?: never + path: { + ptyID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/pty/{ptyID}/connect-token" +} + +export type PtyConnectTokenErrors = { + /** + * Forbidden + */ + 403: EffectHttpApiErrorForbidden + /** + * Not found + */ + 404: NotFoundError +} + +export type PtyConnectTokenError = PtyConnectTokenErrors[keyof PtyConnectTokenErrors] + +export type PtyConnectTokenResponses = { + /** + * WebSocket connect token + */ + 200: { + ticket: string + expires_in: number + } +} + +export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses] + export type QuestionListData = { body?: never path?: never @@ -6652,6 +6693,10 @@ export type PtyConnectData = { } export type PtyConnectErrors = { + /** + * Forbidden + */ + 403: EffectHttpApiErrorForbidden /** * Not found */ From 9f708e748af34cf63c0b1010c4a07ddab1b10ef6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 4 May 2026 02:57:18 +0000 Subject: [PATCH 136/178] chore: generate --- packages/opencode/src/pty/ticket.ts | 4 +- packages/opencode/test/pty/ticket.test.ts | 4 +- packages/sdk/openapi.json | 106 ++++++++++++++++++++++ 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/pty/ticket.ts b/packages/opencode/src/pty/ticket.ts index d40301cad2..b5e5747c51 100644 --- a/packages/opencode/src/pty/ticket.ts +++ b/packages/opencode/src/pty/ticket.ts @@ -28,7 +28,9 @@ export interface Interface { export class Service extends Context.Service()("@opencode/PtyTicket") {} function matches(record: Scope, input: Scope) { - return record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID + return ( + record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID + ) } // Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is diff --git a/packages/opencode/test/pty/ticket.test.ts b/packages/opencode/test/pty/ticket.test.ts index 1b7d6005bf..4886f250f9 100644 --- a/packages/opencode/test/pty/ticket.test.ts +++ b/packages/opencode/test/pty/ticket.test.ts @@ -26,9 +26,7 @@ describe("PTY websocket tickets", () => { const ptyID = PtyID.ascending() const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" }) - expect( - yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket }), - ).toBe(false) + expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false) expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true) }), ) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 21c547c853..6ff18b5155 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3414,6 +3414,91 @@ ] } }, + "/pty/{ptyID}/connect-token": { + "post": { + "tags": ["pty"], + "operationId": "pty.connectToken", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "WebSocket connect token", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["ticket", "expires_in"], + "additionalProperties": false, + "description": "WebSocket connect token" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_Forbidden" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "description": "Create a short-lived ticket for opening a PTY WebSocket connection.", + "summary": "Create PTY WebSocket token", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connectToken({\n ...\n})" + } + ] + } + }, "/question": { "get": { "tags": ["question"], @@ -8327,6 +8412,16 @@ } } }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/effect_HttpApiError_Forbidden" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -12752,6 +12847,17 @@ "required": ["error"], "additionalProperties": false }, + "effect_HttpApiError_Forbidden": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["Forbidden"] + } + }, + "required": ["_tag"], + "additionalProperties": false + }, "ProviderAuthMethod": { "type": "object", "properties": { From a366128a93869ff5868223d3b4116764220b4266 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 4 May 2026 23:24:57 +1000 Subject: [PATCH 137/178] fix(app): prevent terminal recovery loops (#25710) --- packages/app/src/components/terminal.tsx | 22 ++++--- packages/app/src/context/terminal.test.ts | 42 ++++++++++++- packages/app/src/context/terminal.tsx | 62 ++++++++++++++----- packages/app/src/pages/layout.tsx | 3 +- .../app/src/pages/session/terminal-panel.tsx | 20 +++++- 5 files changed, 122 insertions(+), 27 deletions(-) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 7bcc02d62d..d8ed63b8d2 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -480,15 +480,21 @@ export const Terminal = (props: TerminalProps) => { }) const connectToken = async () => { - const result = await client.pty.connectToken( - { ptyID: id }, - { - throwOnError: false, - headers: { "x-opencode-ticket": "1" }, - }, - ) + const result = await client.pty + .connectToken( + { ptyID: id }, + { + throwOnError: false, + headers: { "x-opencode-ticket": "1" }, + }, + ) + .catch((err: unknown) => { + if (err instanceof Error && err.message.includes("Request is not supported")) return + throw err + }) + if (!result) return if (result.response.status === 200 && result.data?.ticket) return result.data.ticket - if ((result.response.status === 404 || result.response.status === 405) && password) return + if (result.response.status === 404 || result.response.status === 405) return if (result.response.status === 403) throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") throw new Error(`PTY connect ticket failed with ${result.response.status}`) diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 6e07e03124..623303fbf4 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -1,6 +1,9 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" -let getWorkspaceTerminalCacheKey: (dir: string) => string +type ServerKey = Parameters[1] + +let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string +let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] let migrateTerminalState: (value: unknown) => unknown @@ -17,6 +20,7 @@ beforeAll(async () => { })) const mod = await import("./terminal") getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey + getTerminalServerScope = mod.getTerminalServerScope getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys migrateTerminalState = mod.migrateTerminalState }) @@ -25,6 +29,42 @@ describe("getWorkspaceTerminalCacheKey", () => { test("uses workspace-only directory cache key", () => { expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__") }) + + test("can include a server scope", () => { + expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__") + }) +}) + +describe("getTerminalServerScope", () => { + test("preserves local server keys", () => { + expect( + getTerminalServerScope( + { type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } }, + "sidecar" as ServerKey, + ), + ).toBeUndefined() + expect( + getTerminalServerScope( + { type: "http", http: { url: "http://localhost:4096" } }, + "http://localhost:4096" as ServerKey, + ), + ).toBeUndefined() + expect( + getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey), + ).toBeUndefined() + }) + + test("scopes non-local server keys", () => { + expect( + getTerminalServerScope( + { type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } }, + "wsl:Debian" as ServerKey, + ), + ).toBe("wsl:Debian" as ServerKey) + expect( + getTerminalServerScope({ type: "http", http: { url: "https://example.com" } }, "https://example.com" as ServerKey), + ).toBe("https://example.com" as ServerKey) + }) }) describe("getLegacyTerminalStorageKeys", () => { diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 31d2d6e04c..0dcebd567d 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -4,6 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" import type { Platform } from "./platform" +import { ServerConnection, useServer } from "./server" import { defaultTitle, titleNumber } from "./terminal-title" import { Persist, persisted, removePersisted } from "@/utils/persist" @@ -82,10 +83,26 @@ export function migrateTerminalState(value: unknown) { } } -export function getWorkspaceTerminalCacheKey(dir: string) { +export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) { + if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}` return `${dir}:${WORKSPACE_KEY}` } +export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) { + if (!conn) return + if (conn.type === "sidecar" && conn.variant === "base") return + if (conn.type === "http") { + try { + const url = new URL(conn.http.url) + if (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]") + return + } catch { + return key + } + } + return key +} + export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) { if (!legacySessionID) return [`${dir}/terminal.v1`] return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`] @@ -110,15 +127,21 @@ const trimTerminal = (pty: LocalPTY) => { } } -export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) { - const key = getWorkspaceTerminalCacheKey(dir) +export function clearWorkspaceTerminals( + dir: string, + sessionIDs?: string[], + platform?: Platform, + scope?: string, +) { + const key = getWorkspaceTerminalCacheKey(dir, scope) for (const cache of caches) { const entry = cache.get(key) entry?.value.clear() } - void removePersisted(Persist.workspace(dir, "terminal"), platform) + void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform) + if (scope) return const legacy = new Set(getLegacyTerminalStorageKeys(dir)) for (const id of sessionIDs ?? []) { for (const key of getLegacyTerminalStorageKeys(dir, id)) { @@ -130,12 +153,17 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat } } -function createWorkspaceTerminalSession(sdk: ReturnType, dir: string, legacySessionID?: string) { - const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID) +function createWorkspaceTerminalSession( + sdk: ReturnType, + dir: string, + legacySessionID?: string, + scope?: string, +) { + const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID) const [store, setStore, _, ready] = persisted( { - ...Persist.workspace(dir, "terminal", legacy), + ...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy), migrate: migrateTerminalState, }, createStore<{ @@ -357,8 +385,12 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont gate: false, init: () => { const sdk = useSDK() + const server = useServer() const params = useParams() const cache = new Map() + const scope = createMemo(() => { + return getTerminalServerScope(server.current, server.key) + }) caches.add(cache) onCleanup(() => caches.delete(cache)) @@ -382,9 +414,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const loadWorkspace = (dir: string, legacySessionID?: string) => { + const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => { // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. - const key = getWorkspaceTerminalCacheKey(dir) + const key = getWorkspaceTerminalCacheKey(dir, serverScope) const existing = cache.get(key) if (existing) { cache.delete(key) @@ -393,7 +425,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } const entry = createRoot((dispose) => ({ - value: createWorkspaceTerminalSession(sdk, dir, legacySessionID), + value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope), dispose, })) @@ -402,16 +434,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => loadWorkspace(params.dir!, params.id)) + const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope())) createEffect( on( - () => ({ dir: params.dir, id: params.id }), + () => ({ dir: params.dir, id: params.id, scope: scope() }), (next, prev) => { if (!prev?.dir) return - if (next.dir === prev.dir && next.id === prev.id) return - if (next.dir === prev.dir && next.id) return - loadWorkspace(prev.dir, prev.id).trimAll() + if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return + if (next.dir === prev.dir && next.id && next.scope === prev.scope) return + loadWorkspace(prev.dir, prev.id, prev.scope).trimAll() }, { defer: true }, ), diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 7e9e2d32aa..a08372649f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -35,7 +35,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" -import { clearWorkspaceTerminals } from "@/context/terminal" +import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal" import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { clearSessionPrefetchInflight, @@ -1557,6 +1557,7 @@ export default function Layout(props: ParentProps) { directory, sessions.map((s) => s.id), platform, + getTerminalServerScope(server.current, server.key), ) await globalSDK.client.instance.dispose({ directory }).catch(() => undefined) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 2c2d9817f0..d7868d9170 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -37,6 +37,7 @@ export function TerminalPanel() { const [store, setStore] = createStore({ autoCreated: false, activeDraggable: undefined as string | undefined, + recovered: {} as Record, view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight), }) @@ -145,6 +146,21 @@ export function TerminalPanel() { const all = terminal.all const ids = createMemo(() => all().map((pty) => pty.id)) + const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise) => { + if (store.recovered[key]) return + setStore("recovered", key, true) + void clone(id) + } + + const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => { + return String(pty.titleNumber || pty.title || pty.id) + } + + const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => { + setStore("recovered", key, false) + trim(id) + } + const handleTerminalDragStart = (event: unknown) => { const id = getDraggableId(event) if (!id) return @@ -280,9 +296,9 @@ export function TerminalPanel() { ops.trim(id)} + onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)} onCleanup={ops.update} - onConnectError={() => ops.clone(id)} + onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)} /> )} From 67047fa7669e17670ae40595cee648a1ad8f0ad8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 4 May 2026 13:26:08 +0000 Subject: [PATCH 138/178] chore: generate --- packages/app/src/context/terminal.test.ts | 5 ++++- packages/app/src/context/terminal.tsx | 14 +++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 623303fbf4..5bca1b4b7e 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -62,7 +62,10 @@ describe("getTerminalServerScope", () => { ), ).toBe("wsl:Debian" as ServerKey) expect( - getTerminalServerScope({ type: "http", http: { url: "https://example.com" } }, "https://example.com" as ServerKey), + getTerminalServerScope( + { type: "http", http: { url: "https://example.com" } }, + "https://example.com" as ServerKey, + ), ).toBe("https://example.com" as ServerKey) }) }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 0dcebd567d..f6751c3f0e 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -94,7 +94,12 @@ export function getTerminalServerScope(conn: ServerConnection.Any | undefined, k if (conn.type === "http") { try { const url = new URL(conn.http.url) - if (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]") + if ( + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "::1" || + url.hostname === "[::1]" + ) return } catch { return key @@ -127,12 +132,7 @@ const trimTerminal = (pty: LocalPTY) => { } } -export function clearWorkspaceTerminals( - dir: string, - sessionIDs?: string[], - platform?: Platform, - scope?: string, -) { +export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) { const key = getWorkspaceTerminalCacheKey(dir, scope) for (const cache of caches) { const entry = cache.get(key) From 1251a870cb384543c150c4a72fb101b55eec971b Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 4 May 2026 15:43:03 +0200 Subject: [PATCH 139/178] fix(opencode): strip transfer-encoding in UI proxy and allow public manifest assets (#25698) Co-authored-by: Kit Langton --- packages/app/src/components/terminal.tsx | 2 +- packages/opencode/src/server/middleware.ts | 2 ++ .../instance/httpapi/middleware/authorization.ts | 2 ++ packages/opencode/src/server/shared/public-ui.ts | 12 ++++++++++++ packages/opencode/src/server/shared/ui.ts | 1 + 5 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/server/shared/public-ui.ts diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index d8ed63b8d2..6dae9de955 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -482,7 +482,7 @@ export const Terminal = (props: TerminalProps) => { const connectToken = async () => { const result = await client.pty .connectToken( - { ptyID: id }, + { ptyID: id, directory }, { throwOnError: false, headers: { "x-opencode-ticket": "1" }, diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts index 898acaf089..160d258796 100644 --- a/packages/opencode/src/server/middleware.ts +++ b/packages/opencode/src/server/middleware.ts @@ -13,6 +13,7 @@ import { compress } from "hono/compress" import * as ServerBackend from "./backend" import { isAllowedCorsOrigin, type CorsOptions } from "./cors" import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket" +import { isPublicUIPath } from "./shared/public-ui" const log = Log.create({ service: "server" }) @@ -45,6 +46,7 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => { if (c.req.method === "OPTIONS") return next() const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() + if (isPublicUIPath(c.req.method, c.req.path)) return next() if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next() const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 6c6d0cd1f1..6f5648f30a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -3,6 +3,7 @@ import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" +import { isPublicUIPath } from "@/server/shared/public-ui" const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 @@ -92,6 +93,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest const url = new URL(request.url, "http://localhost") + if (isPublicUIPath(request.method, url.pathname)) return yield* effect if (hasPtyConnectTicketURL(url)) return yield* effect return yield* credentialFromURL(url, request).pipe( Effect.flatMap((credential) => validateRawCredential(effect, credential, config)), diff --git a/packages/opencode/src/server/shared/public-ui.ts b/packages/opencode/src/server/shared/public-ui.ts new file mode 100644 index 0000000000..fece09592f --- /dev/null +++ b/packages/opencode/src/server/shared/public-ui.ts @@ -0,0 +1,12 @@ +// Static UI assets the browser fetches without app-managed credentials, e.g. +// the manifest link in . These bypass auth so the page can install/render +// the manifest icons even when a server password is configured. +export const PUBLIC_UI_PATHS = new Set([ + "/site.webmanifest", + "/web-app-manifest-192x192.png", + "/web-app-manifest-512x512.png", +]) + +export function isPublicUIPath(method: string, pathname: string) { + return method === "GET" && PUBLIC_UI_PATHS.has(pathname) +} diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index c1558a1a4e..40d8aa7afb 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -33,6 +33,7 @@ function proxyResponseHeaders(headers: Record) { // transfer metadata makes browsers decode already-decoded assets again. result.delete("content-encoding") result.delete("content-length") + result.delete("transfer-encoding") return result } From 6e9f10ad3fbace5df1e3955404c8210528918349 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 09:54:19 -0400 Subject: [PATCH 140/178] test(server): regression reproducers for #25698 (#25714) --- .../test/server/httpapi-listen.test.ts | 40 ++++++++++++ .../opencode/test/server/httpapi-ui.test.ts | 65 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index af4c0a01ce..7258b32a92 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -257,6 +257,46 @@ describe("HttpApi Server.listen", () => { } }) + // Regression for #25698 (Ope): the app's SDK call to + // `client.pty.connectToken({ ptyID })` originally omitted `directory`, so + // the server resolved the PTY in its own cwd context — where the project + // PTY isn't registered — and returned 404. The fix is to always pass + // `directory` from the app side; this test locks in two contracts: + // 1. Mint without directory cannot find a PTY registered in another dir. + // 2. Mint with the project directory succeeds; the resulting ticket + // consumes cleanly when the WS upgrade carries the same directory. + testPty("PTY connect token requires matching directory across mint and connect", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startListener() + try { + const info = await createCat(listener, tmp.path) + + // Mint without directory — server uses its own cwd, can't find the PTY. + const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), { + method: "POST", + headers: { authorization: authorization(), "x-opencode-ticket": "1" }, + }) + expect(ambiguous.status).toBe(404) + + // Mint with the project directory — succeeds, ticket binds to that scope. + const scoped = await fetch( + new URL(`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`, listener.url), + { + method: "POST", + headers: { authorization: authorization(), "x-opencode-ticket": "1" }, + }, + ) + expect(scoped.status).toBe(200) + const mint = (await scoped.json()) as { ticket: string } + + // Same directory on the WS upgrade → consume succeeds. + const ws = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket)) + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up directory-scope listener").catch(() => undefined) + } + }) + testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const listener = await startNoAuthListener() diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index f364491ace..85162f6a92 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -184,6 +184,52 @@ describe("HttpApi UI fallback", () => { expect(await response.text()).toBe("console.log('ok')") }) + // Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was + // forwarded through the proxy while the proxy itself re-frames the body, + // causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`. + test("strips upstream transfer-encoding header from proxied assets", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + const response = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const client = yield* HttpClient.HttpClient + return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), { + fs, + client, + }) + }).pipe( + Effect.provide( + Layer.mergeAll( + AppFileSystem.defaultLayer, + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("opencode", { + headers: { + "transfer-encoding": "chunked", + "content-type": "text/html", + }, + }), + ), + ), + ), + ), + ), + ), + Effect.map(HttpServerResponse.toWeb), + ), + ) + + expect(response.status).toBe(200) + expect(response.headers.get("transfer-encoding")).toBeNull() + expect(await response.text()).toBe("opencode") + }) + test("serves embedded UI assets when Bun can read them but access reports missing", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true let readPath: string | undefined @@ -257,6 +303,25 @@ describe("HttpApi UI fallback", () => { expect(response.status).toBe(200) }) + // Regression for #25698 (Ope): the browser fetches the PWA manifest and + // its icons via flows that don't carry app-managed credentials (the + // `` request is not under page-auth control), so the + // server returning 401 breaks PWA install. These specific public assets + // should bypass auth. + test("serves the PWA manifest without auth even when a server password is set", async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true + + for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) { + const response = await uiApp({ + password: "secret", + username: "opencode", + client: httpClient(new Response("ok")), + }).request(path) + expect(response.status).not.toBe(401) + } + }) + test("allows web UI preflight without auth", async () => { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true From 2c819f290fcb3db83ec12638749959cdc973b5ad Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 4 May 2026 13:55:28 +0000 Subject: [PATCH 141/178] chore: generate --- packages/opencode/test/server/httpapi-listen.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 7258b32a92..98ae30e8a7 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -280,7 +280,10 @@ describe("HttpApi Server.listen", () => { // Mint with the project directory — succeeds, ticket binds to that scope. const scoped = await fetch( - new URL(`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`, listener.url), + new URL( + `${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`, + listener.url, + ), { method: "POST", headers: { authorization: authorization(), "x-opencode-ticket": "1" }, From c1f607d206e7d723d8093650559fffb8a144738e Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 4 May 2026 09:58:21 -0500 Subject: [PATCH 142/178] fix: ensure anthropic sdk properly resolves when using azure (#25721) --- packages/opencode/src/provider/provider.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 939110e044..4013dcee36 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -138,6 +138,14 @@ function useLanguageModel(sdk: any) { return sdk.responses === undefined && sdk.chat === undefined } +function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) { + if (useChat && sdk.chat) return sdk.chat(modelID) + if (sdk.responses) return sdk.responses(modelID) + if (sdk.messages) return sdk.messages(modelID) + if (sdk.chat) return sdk.chat(modelID) + return sdk.languageModel(modelID) +} + function custom(dep: CustomDep): Record { return { anthropic: () => @@ -222,12 +230,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { resourceName: resource, @@ -247,12 +250,7 @@ function custom(dep: CustomDep): Record { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { - if (useLanguageModel(sdk)) return sdk.languageModel(modelID) - if (options?.["useCompletionUrls"]) { - return sdk.chat(modelID) - } else { - return sdk.responses(modelID) - } + return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"])) }, options: { baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined, From 1aed6b1d8bfa5502cdc6997234a0d5be9933ec52 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 4 May 2026 11:16:23 -0400 Subject: [PATCH 143/178] sync --- packages/console/app/src/routes/zen/util/handler.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 2f75668e67..8bab495b72 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -158,11 +158,13 @@ export async function handler( Object.entries(obj).flatMap(([k, v]) => { if (Array.isArray(v)) return [[k, v]] if (typeof v === "object") return [[k, replacer(v)]] - if (v === "$ip") return [[k, ip]] - if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] - if (v.startsWith("$header.")) { - const headerValue = input.request.headers.get(v.slice(8)) - return headerValue ? [[k, headerValue]] : [] + if (typeof v === "string") { + if (v === "$ip") return [[k, ip]] + if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] + if (v.startsWith("$header.")) { + const headerValue = input.request.headers.get(v.slice(8)) + return headerValue ? [[k, headerValue]] : [] + } } return [[k, v]] }), From b70e2700ef38c166730d8af26ac97e36baa660c1 Mon Sep 17 00:00:00 2001 From: Colby Gilbert Date: Mon, 4 May 2026 08:27:03 -0700 Subject: [PATCH 144/178] chore(docs): rename firmware provider to frogbot (#25453) --- packages/web/src/content/docs/ar/providers.mdx | 8 ++++---- packages/web/src/content/docs/bs/providers.mdx | 8 ++++---- packages/web/src/content/docs/da/providers.mdx | 8 ++++---- packages/web/src/content/docs/de/providers.mdx | 8 ++++---- packages/web/src/content/docs/es/providers.mdx | 8 ++++---- packages/web/src/content/docs/fr/providers.mdx | 6 +++--- packages/web/src/content/docs/it/providers.mdx | 8 ++++---- packages/web/src/content/docs/ja/providers.mdx | 4 ++-- packages/web/src/content/docs/ko/providers.mdx | 8 ++++---- packages/web/src/content/docs/nb/providers.mdx | 8 ++++---- packages/web/src/content/docs/pl/providers.mdx | 8 ++++---- packages/web/src/content/docs/providers.mdx | 8 ++++---- packages/web/src/content/docs/pt-br/providers.mdx | 8 ++++---- packages/web/src/content/docs/ru/providers.mdx | 8 ++++---- packages/web/src/content/docs/th/providers.mdx | 8 ++++---- packages/web/src/content/docs/tr/providers.mdx | 8 ++++---- packages/web/src/content/docs/zh-cn/providers.mdx | 8 ++++---- packages/web/src/content/docs/zh-tw/providers.mdx | 8 ++++---- 18 files changed, 69 insertions(+), 69 deletions(-) diff --git a/packages/web/src/content/docs/ar/providers.mdx b/packages/web/src/content/docs/ar/providers.mdx index 07a19b8ad2..c4812fe5d5 100644 --- a/packages/web/src/content/docs/ar/providers.mdx +++ b/packages/web/src/content/docs/ar/providers.mdx @@ -648,17 +648,17 @@ OpenCode Go هي خطة اشتراك منخفضة التكلفة توفّر وص --- -### Firmware +### FrogBot -1. توجّه إلى [Firmware dashboard](https://app.firmware.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API. +1. توجّه إلى [FrogBot dashboard](https://app.frogbot.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API. -2. شغّل الأمر `/connect` وابحث عن **Firmware**. +2. شغّل الأمر `/connect` وابحث عن **FrogBot**. ```txt /connect ``` -3. أدخل مفتاح API الخاص بـ Firmware. +3. أدخل مفتاح API الخاص بـ FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/bs/providers.mdx b/packages/web/src/content/docs/bs/providers.mdx index 4087db8cde..f6e54fc6ad 100644 --- a/packages/web/src/content/docs/bs/providers.mdx +++ b/packages/web/src/content/docs/bs/providers.mdx @@ -653,17 +653,17 @@ Također možete dodati modele kroz svoju opencode konfiguraciju. --- -### Firmware +### FrogBot -1. Idite na [kontrolnu tablu firmvera](https://app.firmware.ai/signup), kreirajte nalog i generišite API ključ. +1. Idite na [kontrolnu tablu firmvera](https://app.frogbot.ai/signup), kreirajte nalog i generišite API ključ. -2. Pokrenite naredbu `/connect` i potražite **Firmware**. +2. Pokrenite naredbu `/connect` i potražite **FrogBot**. ```txt /connect ``` -3. Unesite svoj Firmware API ključ. +3. Unesite svoj FrogBot API ključ. ```txt ┌ API key diff --git a/packages/web/src/content/docs/da/providers.mdx b/packages/web/src/content/docs/da/providers.mdx index 8817d23192..9b04d6be82 100644 --- a/packages/web/src/content/docs/da/providers.mdx +++ b/packages/web/src/content/docs/da/providers.mdx @@ -644,17 +644,17 @@ Cloudflare AI Gateway lader dig få adgang til modeller fra OpenAI, Anthropic, W --- -### Firmware +### FrogBot -1. Gå til [Firmware dashboard](https://app.firmware.ai/signup), opret en konto og generer en API-nøgle. +1. Gå til [FrogBot dashboard](https://app.frogbot.ai/signup), opret en konto og generer en API-nøgle. -2. Kør kommandoen `/connect` og søg efter **Firmware**. +2. Kør kommandoen `/connect` og søg efter **FrogBot**. ```txt /connect ``` -3. Indtast firmware API-nøglen. +3. Indtast frogbot API-nøglen. ```txt ┌ API key diff --git a/packages/web/src/content/docs/de/providers.mdx b/packages/web/src/content/docs/de/providers.mdx index 87f78c9d22..9298146930 100644 --- a/packages/web/src/content/docs/de/providers.mdx +++ b/packages/web/src/content/docs/de/providers.mdx @@ -650,17 +650,17 @@ Mit dem Cloudflare AI Gateway können Sie über einen einheitlichen Endpunkt auf --- -### Firmware +### FrogBot -1. Gehen Sie zu [Firmware dashboard](https://app.firmware.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel. +1. Gehen Sie zu [FrogBot dashboard](https://app.frogbot.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel. -2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **Firmware**. +2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **FrogBot**. ```txt /connect ``` -3. Geben Sie Ihren Firmware API-Schlüssel ein. +3. Geben Sie Ihren FrogBot API-Schlüssel ein. ```txt ┌ API key diff --git a/packages/web/src/content/docs/es/providers.mdx b/packages/web/src/content/docs/es/providers.mdx index b44ce9ee99..11489609bc 100644 --- a/packages/web/src/content/docs/es/providers.mdx +++ b/packages/web/src/content/docs/es/providers.mdx @@ -651,17 +651,17 @@ Cloudflare AI Gateway le permite acceder a modelos de OpenAI, Anthropic, Workers --- -### Firmware +### FrogBot -1. Dirígete al [Panel de firmware](https://app.firmware.ai/signup), crea una cuenta y genera una clave API. +1. Dirígete al [Panel de frogbot](https://app.frogbot.ai/signup), crea una cuenta y genera una clave API. -2. Ejecute el comando `/connect` y busque **Firmware**. +2. Ejecute el comando `/connect` y busque **FrogBot**. ```txt /connect ``` -3. Ingrese su clave de firmware API. +3. Ingrese su clave de frogbot API. ```txt ┌ API key diff --git a/packages/web/src/content/docs/fr/providers.mdx b/packages/web/src/content/docs/fr/providers.mdx index 6a902ab02f..90bdb1fbc3 100644 --- a/packages/web/src/content/docs/fr/providers.mdx +++ b/packages/web/src/content/docs/fr/providers.mdx @@ -654,11 +654,11 @@ Vous pouvez également ajouter des modèles via votre configuration opencode. --- -### Firmware +### FrogBot -1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.firmware.ai/signup), créez un compte et générez une clé API. +1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.frogbot.ai/signup), créez un compte et générez une clé API. -2. Exécutez la commande `/connect` et recherchez **Firmware**. +2. Exécutez la commande `/connect` et recherchez **FrogBot**. ```txt /connect diff --git a/packages/web/src/content/docs/it/providers.mdx b/packages/web/src/content/docs/it/providers.mdx index 96da8c4df1..f2d195d721 100644 --- a/packages/web/src/content/docs/it/providers.mdx +++ b/packages/web/src/content/docs/it/providers.mdx @@ -628,17 +628,17 @@ Cloudflare AI Gateway ti permette di accedere a modelli di OpenAI, Anthropic, Wo --- -### Firmware +### FrogBot -1. Vai alla [dashboard di Firmware](https://app.firmware.ai/signup), crea un account e genera una chiave API. +1. Vai alla [dashboard di FrogBot](https://app.frogbot.ai/signup), crea un account e genera una chiave API. -2. Esegui il comando `/connect` e cerca **Firmware**. +2. Esegui il comando `/connect` e cerca **FrogBot**. ```txt /connect ``` -3. Inserisci la tua chiave API di Firmware. +3. Inserisci la tua chiave API di FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/ja/providers.mdx b/packages/web/src/content/docs/ja/providers.mdx index 8017d0882e..c969c6d4a0 100644 --- a/packages/web/src/content/docs/ja/providers.mdx +++ b/packages/web/src/content/docs/ja/providers.mdx @@ -658,9 +658,9 @@ OpenCode 設定を通じてモデルを追加することもできます。 --- -### Firmware +### FrogBot -1. [ファームウェアダッシュボード](https://app.firmware.ai/signup) に移動し、アカウントを作成し、API キーを生成します。 +1. [ファームウェアダッシュボード](https://app.frogbot.ai/signup) に移動し、アカウントを作成し、API キーを生成します。 2. `/connect` コマンドを実行し、**ファームウェア**を検索します。 diff --git a/packages/web/src/content/docs/ko/providers.mdx b/packages/web/src/content/docs/ko/providers.mdx index 6ca3afccc3..87278bef23 100644 --- a/packages/web/src/content/docs/ko/providers.mdx +++ b/packages/web/src/content/docs/ko/providers.mdx @@ -654,17 +654,17 @@ Cloudflare AI Gateway는 OpenAI, Anthropic, Workers AI 등의 모델에 액세 --- -### Firmware +### FrogBot -1. [Firmware 대시보드](https://app.firmware.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다. +1. [FrogBot 대시보드](https://app.frogbot.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다. -2. `/connect` 명령을 실행하고 **Firmware**를 검색하십시오. +2. `/connect` 명령을 실행하고 **FrogBot**를 검색하십시오. ```txt /connect ``` -3. Firmware API 키를 입력하십시오. +3. FrogBot API 키를 입력하십시오. ```txt ┌ API key diff --git a/packages/web/src/content/docs/nb/providers.mdx b/packages/web/src/content/docs/nb/providers.mdx index 1fe8812e67..bf276918a9 100644 --- a/packages/web/src/content/docs/nb/providers.mdx +++ b/packages/web/src/content/docs/nb/providers.mdx @@ -652,17 +652,17 @@ Cloudflare AI Gateway lar deg få tilgang til modeller fra OpenAI, Anthropic, Wo --- -### Firmware +### FrogBot -1. Gå over til [Firmware dashboard](https://app.firmware.ai/signup), opprett en konto og generer en API nøkkel. +1. Gå over til [FrogBot dashboard](https://app.frogbot.ai/signup), opprett en konto og generer en API nøkkel. -2. Kjør kommandoen `/connect` og søk etter **Firmware**. +2. Kjør kommandoen `/connect` og søk etter **FrogBot**. ```txt /connect ``` -3. Skriv inn firmware API nøkkelen. +3. Skriv inn frogbot API nøkkelen. ```txt ┌ API key diff --git a/packages/web/src/content/docs/pl/providers.mdx b/packages/web/src/content/docs/pl/providers.mdx index deadd07d6a..0e722d5fde 100644 --- a/packages/web/src/content/docs/pl/providers.mdx +++ b/packages/web/src/content/docs/pl/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway umożliwia dostęp do modeli z OpenAI, Anthropic, Workers --- -### Firmware +### FrogBot -1. Przejdź do [Firmware dashboard](https://app.firmware.ai/signup), utwórz konto i wygeneruj klucz API. +1. Przejdź do [FrogBot dashboard](https://app.frogbot.ai/signup), utwórz konto i wygeneruj klucz API. -2. Uruchom polecenie `/connect` i wyszukaj **Firmware**. +2. Uruchom polecenie `/connect` i wyszukaj **FrogBot**. ```txt /connect ``` -3. Wprowadź klucz API Firmware. +3. Wprowadź klucz API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 7c395022c1..8410c549f2 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -721,17 +721,17 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire --- -### Firmware +### FrogBot -1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key. +1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key. -2. Run the `/connect` command and search for **Firmware**. +2. Run the `/connect` command and search for **FrogBot**. ```txt /connect ``` -3. Enter your Firmware API key. +3. Enter your FrogBot API key. ```txt ┌ API key diff --git a/packages/web/src/content/docs/pt-br/providers.mdx b/packages/web/src/content/docs/pt-br/providers.mdx index 50f841cf36..174bc1679b 100644 --- a/packages/web/src/content/docs/pt-br/providers.mdx +++ b/packages/web/src/content/docs/pt-br/providers.mdx @@ -654,17 +654,17 @@ O Cloudflare AI Gateway permite que você acesse modelos do OpenAI, Anthropic, W --- -### Firmware +### FrogBot -1. Acesse o [painel Firmware](https://app.firmware.ai/signup), crie uma conta e gere uma chave da API. +1. Acesse o [painel FrogBot](https://app.frogbot.ai/signup), crie uma conta e gere uma chave da API. -2. Execute o comando `/connect` e procure por **Firmware**. +2. Execute o comando `/connect` e procure por **FrogBot**. ```txt /connect ``` -3. Insira sua chave da API Firmware. +3. Insira sua chave da API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/ru/providers.mdx b/packages/web/src/content/docs/ru/providers.mdx index f5868ceaa0..39aae9e096 100644 --- a/packages/web/src/content/docs/ru/providers.mdx +++ b/packages/web/src/content/docs/ru/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway позволяет вам получать доступ к --- -### Firmware +### FrogBot -1. Перейдите на [панель Firmware](https://app.firmware.ai/signup), создайте учетную запись и сгенерируйте ключ API. +1. Перейдите на [панель FrogBot](https://app.frogbot.ai/signup), создайте учетную запись и сгенерируйте ключ API. -2. Запустите команду `/connect` и найдите **Firmware**. +2. Запустите команду `/connect` и найдите **FrogBot**. ```txt /connect ``` -3. Введите ключ API Firmware. +3. Введите ключ API FrogBot. ```txt ┌ API key diff --git a/packages/web/src/content/docs/th/providers.mdx b/packages/web/src/content/docs/th/providers.mdx index 818f39213c..07008de218 100644 --- a/packages/web/src/content/docs/th/providers.mdx +++ b/packages/web/src/content/docs/th/providers.mdx @@ -650,17 +650,17 @@ Cloudflare AI Gateway ช่วยให้คุณเข้าถึงโม --- -### Firmware +### FrogBot -1. ไปที่ [แดชบอร์ด Firmware](https://app.firmware.ai/signup) สร้างบัญชี และสร้างคีย์ API +1. ไปที่ [แดชบอร์ด FrogBot](https://app.frogbot.ai/signup) สร้างบัญชี และสร้างคีย์ API -2. เรียกใช้คำสั่ง `/connect` และค้นหา **Firmware** +2. เรียกใช้คำสั่ง `/connect` และค้นหา **FrogBot** ```txt /connect ``` -3. ป้อนคีย์ Firmware API ของคุณ +3. ป้อนคีย์ FrogBot API ของคุณ ```txt ┌ API key diff --git a/packages/web/src/content/docs/tr/providers.mdx b/packages/web/src/content/docs/tr/providers.mdx index 527c20e15e..8c6ef23fee 100644 --- a/packages/web/src/content/docs/tr/providers.mdx +++ b/packages/web/src/content/docs/tr/providers.mdx @@ -652,17 +652,17 @@ Cloudflare AI Gateway, OpenAI, Anthropic, Workers AI ve daha fazlasındaki model --- -### Firmware +### FrogBot -1. [Firmware dashboard](https://app.firmware.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun. +1. [FrogBot dashboard](https://app.frogbot.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun. -2. `/connect` komutunu çalıştırın ve **Firmware**'i arayın. +2. `/connect` komutunu çalıştırın ve **FrogBot**'i arayın. ```txt /connect ``` -3. Firmware API anahtarınızı girin. +3. FrogBot API anahtarınızı girin. ```txt ┌ API key diff --git a/packages/web/src/content/docs/zh-cn/providers.mdx b/packages/web/src/content/docs/zh-cn/providers.mdx index 80dfe1e93d..9c0a5d8a3b 100644 --- a/packages/web/src/content/docs/zh-cn/providers.mdx +++ b/packages/web/src/content/docs/zh-cn/providers.mdx @@ -624,17 +624,17 @@ Cloudflare AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic --- -### Firmware +### FrogBot -1. 前往 [Firmware 仪表盘](https://app.firmware.ai/signup),创建账户并生成 API 密钥。 +1. 前往 [FrogBot 仪表盘](https://app.frogbot.ai/signup),创建账户并生成 API 密钥。 -2. 执行 `/connect` 命令并搜索 **Firmware**。 +2. 执行 `/connect` 命令并搜索 **FrogBot**。 ```txt /connect ``` -3. 输入你的 Firmware API 密钥。 +3. 输入你的 FrogBot API 密钥。 ```txt ┌ API key diff --git a/packages/web/src/content/docs/zh-tw/providers.mdx b/packages/web/src/content/docs/zh-tw/providers.mdx index c874170959..d4e55ed712 100644 --- a/packages/web/src/content/docs/zh-tw/providers.mdx +++ b/packages/web/src/content/docs/zh-tw/providers.mdx @@ -645,17 +645,17 @@ Cloudflare AI Gateway 允許您透過統一端點存取來自 OpenAI、Anthropic --- -### Firmware +### FrogBot -1. 前往 [Firmware 儀表板](https://app.firmware.ai/signup),建立帳號並產生 API 金鑰。 +1. 前往 [FrogBot 儀表板](https://app.frogbot.ai/signup),建立帳號並產生 API 金鑰。 -2. 執行 `/connect` 指令並搜尋 **Firmware**。 +2. 執行 `/connect` 指令並搜尋 **FrogBot**。 ```txt /connect ``` -3. 輸入您的 Firmware API 金鑰。 +3. 輸入您的 FrogBot API 金鑰。 ```txt ┌ API key From 25dc6f09bca2f9b90b7594e0a696f451f22f1254 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 12:01:13 -0400 Subject: [PATCH 145/178] fix(worktree): fork workspace worktree boot (#25723) --- packages/opencode/src/worktree/index.ts | 11 +- .../opencode/test/project/worktree.test.ts | 4 +- .../server/worktree-endpoint-repro.test.ts | 148 ++++++++++++++++++ 3 files changed, 156 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/test/server/worktree-endpoint-repro.test.ts diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 43453b561a..f4e4d2721c 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -291,16 +291,15 @@ export const layer: Layer.Layer< const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { yield* setup(info) - yield* boot(info, startCommand) + yield* boot(info, startCommand).pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), + Effect.forkIn(scope), + ) }) const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) { const info = yield* makeWorktreeInfo(input?.name) - yield* setup(info) - yield* boot(info, input?.startCommand).pipe( - Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))), - Effect.forkIn(scope), - ) + yield* createFromInfo(info, input?.startCommand) return info }) diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index a89fda6ca5..b191a3c952 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -178,12 +178,13 @@ describe("Worktree", () => { }) describe("createFromInfo", () => { - wintest("creates and bootstraps git worktree", () => + wintest("creates git worktree and boots asynchronously", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { const svc = yield* Worktree.Service const info = yield* svc.makeWorktreeInfo("from-info-test") + const ready = waitReady() yield* svc.createFromInfo(info) const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text()) @@ -191,6 +192,7 @@ describe("Worktree", () => { const normalizedDir = info.directory.replace(/\\/g, "/") expect(normalizedList).toContain(normalizedDir) + yield* Effect.promise(() => ready) yield* svc.remove({ directory: info.directory }) }), { git: true }, diff --git a/packages/opencode/test/server/worktree-endpoint-repro.test.ts b/packages/opencode/test/server/worktree-endpoint-repro.test.ts new file mode 100644 index 0000000000..768a261a00 --- /dev/null +++ b/packages/opencode/test/server/worktree-endpoint-repro.test.ts @@ -0,0 +1,148 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { Flag } from "@opencode-ai/core/flag/flag" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" +import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental" +import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const stateLayer = Layer.effectDiscard( + Effect.gen(function* () { + const original = { + OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI, + OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + } + + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES + await resetDatabase() + }), + ) + }), +) + +const it = testEffect(stateLayer) +type TestServer = ReturnType + +function serverScoped() { + return Effect.acquireRelease( + Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })), + (server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore), + ) +} + +function request(server: TestServer, input: string, init?: RequestInit) { + return Effect.promise(() => + server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context), + ) +} + +function withRequestTimeout(effect: Effect.Effect, label: string, ms = 5_000) { + return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label)) +} + +function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) { + return Effect.gen(function* () { + const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`) + expect(current.status).toBe(200) + const project = (yield* Effect.promise(() => current.json())) as { id: string } + const updated = yield* request( + input.server, + `/project/${project.id}?directory=${encodeURIComponent(input.directory)}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ commands: { start: input.command } }), + }, + ) + expect(updated.status).toBe(200) + }) +} + +describe("worktree endpoint reproduction", () => { + it.instance( + "direct HttpApi worktree create returns without waiting for boot", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* withRequestTimeout( + request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }), + "direct worktree create", + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) }) + }), + { git: true }, + ) + + it.instance( + "workspace worktree create does not hang", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create", + 8_000, + ) + + expect(response.status).toBe(200) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + type: "worktree", + directory: expect.any(String), + }) + }), + { git: true }, + ) + + it.instance( + "workspace worktree create returns without waiting for project start command", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const server = yield* serverScoped() + yield* setProjectStartCommand({ + server, + directory: test.directory, + command: 'bun -e "setTimeout(() => {}, 2000)"', + }) + + const started = Date.now() + const response = yield* withRequestTimeout( + request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "worktree", branch: null }), + }), + "workspace worktree create with project start command", + 6_000, + ) + + expect(response.status).toBe(200) + expect(Date.now() - started).toBeLessThan(1_500) + }), + { git: true }, + ) +}) From fb07c2070cba705bf0e9766a5a7ce6a3452797fb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 13:06:29 -0400 Subject: [PATCH 146/178] fix(server): provide fresh ConfigProvider per HttpApi listener (#25726) --- packages/opencode/src/server/server.ts | 8 ++++- .../test/server/httpapi-listen.test.ts | 34 ++++++++++--------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 3971214f3d..ca86599955 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,7 +5,7 @@ import { lazy } from "@/util/lazy" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { WorkspaceID } from "@/control-plane/schema" -import { Context, Effect, Exit, Layer, Scope } from "effect" +import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import * as HttpApiServer from "#httpapi-server" @@ -259,6 +259,12 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec }).pipe( Layer.provideMerge(WebSocketTracker.layer), Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })), + // Install a fresh `ConfigProvider` per listener so `Config.string(...)` + // reads reflect the current `process.env`. Effect's default + // `ConfigProvider` snapshots `process.env` on first read and caches the + // result on a module-singleton Reference; without overriding it here, + // every later `Server.listen()` keeps observing that initial snapshot. + Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv())), ) const start = async (port: number) => { diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 98ae30e8a7..b49fbe98b5 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -40,8 +40,8 @@ async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpap return Server.listen({ hostname: "127.0.0.1", port: 0 }) } -async function startNoAuthListener() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false +async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" Flag.OPENCODE_SERVER_PASSWORD = undefined Flag.OPENCODE_SERVER_USERNAME = auth.username delete process.env.OPENCODE_SERVER_PASSWORD @@ -300,18 +300,20 @@ describe("HttpApi Server.listen", () => { } }) - testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startNoAuthListener() - try { - const info = await createCat(listener, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path)) - const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) - ws.send("ping-no-auth\n") - expect(await message).toContain("ping-no-auth") - ws.close(1000) - } finally { - await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) - } - }) + for (const backend of ["effect-httpapi", "hono"] as const) { + testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener(backend) + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`)) + ws.send(`ping-no-auth-${backend}\n`) + expect(await message).toContain(`ping-no-auth-${backend}`) + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) + } }) From 007b57f0788b129a993228b5f1c340c640e94ea9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 13:11:33 -0400 Subject: [PATCH 147/178] test(agent): skip InstanceBootstrap in plugin-agent regression test (#25737) --- .../agent/plugin-agent-regression.test.ts | 73 +++++-------------- .../test/fixture/agent-plugin.constants.ts | 6 ++ .../opencode/test/fixture/agent-plugin.ts | 12 +++ 3 files changed, 36 insertions(+), 55 deletions(-) create mode 100644 packages/opencode/test/fixture/agent-plugin.constants.ts create mode 100644 packages/opencode/test/fixture/agent-plugin.ts diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 3ac923c435..dff972d100 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,65 +1,28 @@ import { expect } from "bun:test" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Effect, Layer } from "effect" import path from "path" import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" -import { InstanceRef } from "../../src/effect/instance-ref" -import { InstanceLayer } from "../../src/project/instance-layer" -import { InstanceStore } from "../../src/project/instance-store" -import { tmpdirScoped } from "../fixture/fixture" +import { Plugin } from "../../src/plugin" import { testEffect } from "../lib/effect" +import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" -const pluginAgent = { - name: "plugin_added", - description: "Added by a plugin via the config hook", - mode: "subagent", -} as const +// `it.instance` skips InstanceBootstrap so FileWatcher / LSP / MCP don't spin +// up — those services hang during scope teardown on Windows and aren't needed +// to verify plugin → config hook → Agent.list. +const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href -const it = testEffect(Layer.mergeAll(Agent.defaultLayer, InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer)) -it.live("plugin-registered agents appear in Agent.list", () => - Effect.gen(function* () { - const dir = yield* tmpdirScoped() - const pluginFile = path.join(dir, "plugin.ts") - - yield* Effect.promise(async () => { - await Promise.all([ - Bun.write( - pluginFile, - [ - "export default async () => ({", - " config: async (cfg) => {", - " cfg.agent = cfg.agent ?? {}", - ` cfg.agent[${JSON.stringify(pluginAgent.name)}] = {`, - ` description: ${JSON.stringify(pluginAgent.description)},`, - ` mode: ${JSON.stringify(pluginAgent.mode)},`, - " }", - " },", - "})", - "", - ].join("\n"), - ), - Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [pathToFileURL(pluginFile).href], - }), - ), - ]) - }) - - const agents = yield* InstanceStore.Service.use((store) => - Effect.gen(function* () { - const ctx = yield* store.load({ directory: dir }) - yield* Effect.addFinalizer(() => store.dispose(ctx).pipe(Effect.ignore)) - return yield* Agent.Service.use((svc) => svc.list()).pipe(Effect.provideService(InstanceRef, ctx)) - }), - ) - const added = agents.find((agent) => agent.name === pluginAgent.name) - - expect(added?.description).toBe(pluginAgent.description) - expect(added?.mode).toBe(pluginAgent.mode) - }), +it.instance( + "plugin-registered agents appear in Agent.list", + () => + Effect.gen(function* () { + yield* Plugin.Service.use((p) => p.init()) + const agents = yield* Agent.Service.use((svc) => svc.list()) + const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name) + expect(added?.description).toBe(PLUGIN_AGENT.description) + expect(added?.mode).toBe(PLUGIN_AGENT.mode) + }), + { config: { plugin: [pluginUrl] } }, ) diff --git a/packages/opencode/test/fixture/agent-plugin.constants.ts b/packages/opencode/test/fixture/agent-plugin.constants.ts new file mode 100644 index 0000000000..9dd5f3910e --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.constants.ts @@ -0,0 +1,6 @@ +// Separate file because every export in `agent-plugin.ts` must be a function. +export const PLUGIN_AGENT = { + name: "plugin_added", + description: "Added by a plugin via the config hook", + mode: "subagent", +} as const diff --git a/packages/opencode/test/fixture/agent-plugin.ts b/packages/opencode/test/fixture/agent-plugin.ts new file mode 100644 index 0000000000..892f636466 --- /dev/null +++ b/packages/opencode/test/fixture/agent-plugin.ts @@ -0,0 +1,12 @@ +// Every export in this file must be a plugin function — `getLegacyPlugins` +// (src/plugin/index.ts) throws on anything else. Test constants live in +// `agent-plugin.constants.ts`. +export default async () => ({ + config: async (cfg: { agent?: Record }) => { + cfg.agent = cfg.agent ?? {} + cfg.agent["plugin_added"] = { + description: "Added by a plugin via the config hook", + mode: "subagent", + } + }, +}) From 5720883d5d8b2e823cb7a6c81350973f7b7f0b79 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 4 May 2026 15:51:29 -0400 Subject: [PATCH 148/178] sync --- packages/console/app/src/routes/zen/util/handler.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 8bab495b72..7f36246ee5 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -919,6 +919,13 @@ export async function handler( "tokens.cache_read": cacheReadTokens, "tokens.cache_write_5m": cacheWrite5mTokens, "tokens.cache_write_1h": cacheWrite1hTokens, + "cost.input.microcents": centsToMicroCents(inputCost), + "cost.output.microcents": centsToMicroCents(outputCost), + "cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined, + "cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined, + "cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined, + "cost.total.microcents": centsToMicroCents(totalCostInCent), + // deprecated - remove after May 20, 2026 "cost.input": Math.round(inputCost), "cost.output": Math.round(outputCost), "cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined, From d431a0e4b47fbf586ad3d23390b3c5e36911fb37 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 4 May 2026 17:29:00 -0500 Subject: [PATCH 149/178] fix: ensure effect server middleware properly parses errors (#25717) --- .../instance/httpapi/middleware/error.ts | 58 +++++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 2 + 2 files changed, 60 insertions(+) create mode 100644 packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts new file mode 100644 index 0000000000..6f3c33a647 --- /dev/null +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/error.ts @@ -0,0 +1,58 @@ +import { Provider } from "@/provider/provider" +import { Session } from "@/session/session" +import { NotFoundError } from "@/storage/storage" +import { iife } from "@/util/iife" +import { NamedError } from "@opencode-ai/core/util/error" +import * as Log from "@opencode-ai/core/util/log" +import { Cause, Effect } from "effect" +import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http" + +const log = Log.create({ service: "server" }) + +// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s. +export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) => + effect.pipe( + Effect.catchCause((cause) => { + const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => { + if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false + if (HttpServerError.isHttpServerError(reason.defect)) return false + if (HttpServerRespondable.isRespondable(reason.defect)) return false + return true + }) + if (!defect) return Effect.failCause(cause) + + const error = defect.defect + log.error("failed", { error, cause: Cause.pretty(cause) }) + + if (error instanceof NamedError) { + return Effect.succeed( + HttpServerResponse.jsonUnsafe(error.toObject(), { + status: iife(() => { + if (error instanceof NotFoundError) return 404 + if (error instanceof Provider.ModelNotFoundError) return 400 + if (error.name === "ProviderAuthValidationFailed") return 400 + if (error.name.startsWith("Worktree")) return 400 + return 500 + }), + }), + ) + } + if (error instanceof Session.BusyError) { + return Effect.succeed( + HttpServerResponse.jsonUnsafe(new NamedError.Unknown({ message: error.message }).toObject(), { + status: 400, + }), + ) + } + + return Effect.succeed( + HttpServerResponse.jsonUnsafe( + new NamedError.Unknown({ + message: error instanceof Error && error.stack ? error.stack : String(error), + }).toObject(), + { status: 500 }, + ), + ) + }), + ), +).layer diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index a3754c2e19..ef966036a9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -73,6 +73,7 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" +import { errorLayer } from "./middleware/error" export const context = Context.makeUnsafe(new Map()) @@ -144,6 +145,7 @@ const uiRoute = HttpRouter.use((router) => export function createRoutes(corsOptions?: CorsOptions) { return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( Layer.provide([ + errorLayer, cors(corsOptions), runtime, Account.defaultLayer, From 4b65b1e0532b6f6cab101f2aba0c26a318fb36d8 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 4 May 2026 23:26:02 +0000 Subject: [PATCH 150/178] sync release versions for v1.14.34 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 25068f3d9a..3cf2d9ce99 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.33", + "version": "1.14.34", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.33", + "version": "1.14.34", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 5f4d79e44f..ac9bfd5904 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.33", + "version": "1.14.34", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index cb5b4bf9a4..85e855c55f 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index bfb7f7db8f..d5157a372c 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.33", + "version": "1.14.34", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index f6072bd379..0bb1265419 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.33", + "version": "1.14.34", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index d73a23e081..b685bb1aab 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.33", + "version": "1.14.34", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 4ba8d1401b..5f3371b988 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.33", + "version": "1.14.34", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 7a26516a99..8a6fcf5786 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1327423e51..2ec5cd0594 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 16e142b9cf..fe9de85848 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.33", + "version": "1.14.34", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index d9e71219f5..17b51a6257 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.33" +version = "1.14.34" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 1eb790cced..f9044078b7 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.33", + "version": "1.14.34", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index adb4a7db1b..08d3171510 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.33", + "version": "1.14.34", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d6bfdd844b..a8c17f19f4 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index de69e685c5..b3e12fc253 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 04b996aca7..8a2ba85b02 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index cd210c4d61..0c86216238 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.33", + "version": "1.14.34", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index c346fe5e7e..8187602b09 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.33", + "version": "1.14.34", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 67617771f0..43f07930ef 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.33", + "version": "1.14.34", "publisher": "sst-dev", "repository": { "type": "git", From 6a5e329427458619749f9c83e5374b249f87322c Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 5 May 2026 10:34:06 +1000 Subject: [PATCH 151/178] fix(vcs): preserve batched patch boundaries (#25787) --- packages/opencode/src/project/vcs.ts | 4 ++- packages/opencode/test/project/vcs.test.ts | 27 ++++++++++++++ .../ui/src/components/session-diff.test.ts | 16 +++++++++ packages/ui/src/components/session-diff.ts | 35 ++++++++++--------- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 28ac143eec..8b3bedbf5b 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -85,7 +85,9 @@ const fileFromPatchChunk = (chunk: string) => { } const splitGitPatch = (patch: Git.Patch) => { - const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index) + const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) => + match[0].startsWith("\n") ? match.index + 1 : match.index, + ) const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length)) if (!patch.truncated) return chunks return chunks.slice(0, -1) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 53ff547ac1..06da6ccba1 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,5 +1,6 @@ import { $ } from "bun" import { afterEach, describe, expect, test } from "bun:test" +import { parsePatch } from "diff" import { Effect } from "effect" import fs from "fs/promises" import path from "path" @@ -288,6 +289,32 @@ describe("Vcs diff", () => { }) }) + test( + "diff('git') keeps carriage returns inside patch hunks", + async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") + + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const file = diff.find((item) => item.file === "file.txt") + + expect(file?.patch).toContain(" same\rdiff --git inside") + expect(file?.patch).toContain("-delete") + expect(() => parsePatch(file?.patch ?? "")).not.toThrow() + }) + }, + 20_000, + ) + test("diff('branch') returns changes against default branch", async () => { await using tmp = await tmpdir({ git: true }) await $`git branch -M main`.cwd(tmp.path).quiet() diff --git a/packages/ui/src/components/session-diff.test.ts b/packages/ui/src/components/session-diff.test.ts index 463a729778..edaa15b84b 100644 --- a/packages/ui/src/components/session-diff.test.ts +++ b/packages/ui/src/components/session-diff.test.ts @@ -34,4 +34,20 @@ describe("session diff", () => { expect(text(view, "deletions")).toBe("one\n") expect(text(view, "additions")).toBe("two\n") }) + + test("ignores malformed persisted patches", () => { + const diff = { + file: "a.ts", + patch: + "diff --git a/a.ts b/a.ts\nindex ff4ceb2..65a1de0 100644\n--- a/a.ts\n+++ b/a.ts\n@@ -1,3 +1,3 @@\n keep\n+add\n same\r", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(view.patch).toBe(diff.patch) + expect(text(view, "deletions")).toBe("") + expect(text(view, "additions")).toBe("") + }) }) diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index a5fbdbc5c0..2da8c61a76 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -27,26 +27,29 @@ const cache = new Map() function patch(diff: ReviewDiff) { if (typeof diff.patch === "string") { - const [patch] = parsePatch(diff.patch) + try { + const [patch] = parsePatch(diff.patch) + const beforeLines = [] + const afterLines = [] - const beforeLines = [] - const afterLines = [] - - for (const hunk of patch.hunks) { - for (const line of hunk.lines) { - if (line.startsWith("-")) { - beforeLines.push(line.slice(1)) - } else if (line.startsWith("+")) { - afterLines.push(line.slice(1)) - } else { - // context line (starts with ' ') - beforeLines.push(line.slice(1)) - afterLines.push(line.slice(1)) + for (const hunk of patch.hunks) { + for (const line of hunk.lines) { + if (line.startsWith("-")) { + beforeLines.push(line.slice(1)) + } else if (line.startsWith("+")) { + afterLines.push(line.slice(1)) + } else { + // context line (starts with ' ') + beforeLines.push(line.slice(1)) + afterLines.push(line.slice(1)) + } } } - } - return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch } + return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch } + } catch { + return { before: "", after: "", patch: diff.patch } + } } return { before: "before" in diff && typeof diff.before === "string" ? diff.before : "", From f14784d5319c5fc4f6e298819d8112ee6aa5342c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 5 May 2026 00:35:18 +0000 Subject: [PATCH 152/178] chore: generate --- packages/opencode/test/project/vcs.test.ts | 44 ++++++++++------------ 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 06da6ccba1..82eacfb6df 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -289,31 +289,27 @@ describe("Vcs diff", () => { }) }) - test( - "diff('git') keeps carriage returns inside patch hunks", - async () => { - await using tmp = await tmpdir({ git: true }) - await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") - await $`git add .`.cwd(tmp.path).quiet() - await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() - await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") - - await withVcsOnly(tmp.path, async () => { - const diff = await AppRuntime.runPromise( - Effect.gen(function* () { - const vcs = yield* Vcs.Service - return yield* vcs.diff("git") - }), - ) - const file = diff.find((item) => item.file === "file.txt") + test("diff('git') keeps carriage returns inside patch hunks", async () => { + await using tmp = await tmpdir({ git: true }) + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") + await $`git add .`.cwd(tmp.path).quiet() + await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() + await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8") - expect(file?.patch).toContain(" same\rdiff --git inside") - expect(file?.patch).toContain("-delete") - expect(() => parsePatch(file?.patch ?? "")).not.toThrow() - }) - }, - 20_000, - ) + await withVcsOnly(tmp.path, async () => { + const diff = await AppRuntime.runPromise( + Effect.gen(function* () { + const vcs = yield* Vcs.Service + return yield* vcs.diff("git") + }), + ) + const file = diff.find((item) => item.file === "file.txt") + + expect(file?.patch).toContain(" same\rdiff --git inside") + expect(file?.patch).toContain("-delete") + expect(() => parsePatch(file?.patch ?? "")).not.toThrow() + }) + }, 20_000) test("diff('branch') returns changes against default branch", async () => { await using tmp = await tmpdir({ git: true }) From 6b852774e18c2bdabfd8754d3e1c506c7db76bff Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 5 May 2026 01:01:47 +0000 Subject: [PATCH 153/178] sync release versions for v1.14.35 --- bun.lock | 32 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 3cf2d9ce99..07415dd79f 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -85,7 +85,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -119,7 +119,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -146,7 +146,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -170,7 +170,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -194,7 +194,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.14.34", + "version": "1.14.35", "bin": { "opencode": "./bin/opencode", }, @@ -228,7 +228,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -263,7 +263,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -309,7 +309,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -354,7 +354,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.14.34", + "version": "1.14.35", "bin": { "opencode": "./bin/opencode", }, @@ -496,7 +496,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@opencode-ai/sdk": "workspace:*", "effect": "catalog:", @@ -531,7 +531,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "cross-spawn": "catalog:", }, @@ -546,7 +546,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -581,7 +581,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -630,7 +630,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index ac9bfd5904..cde4986d18 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.14.34", + "version": "1.14.35", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 85e855c55f..fb2e71d22d 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index d5157a372c..7301b23e5c 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.14.34", + "version": "1.14.35", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 0bb1265419..06fb0affd0 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.14.34", + "version": "1.14.35", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index b685bb1aab..674fc55fd5 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.14.34", + "version": "1.14.35", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/core/package.json b/packages/core/package.json index 5f3371b988..e90ab7628a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.34", + "version": "1.14.35", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 8a6fcf5786..ba981e637a 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 2ec5cd0594..e60320300a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index fe9de85848..dce25e204d 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.14.34", + "version": "1.14.35", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 17b51a6257..775f826d4c 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.14.34" +version = "1.14.35" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.35/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index f9044078b7..1039677b52 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.14.34", + "version": "1.14.35", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 08d3171510..bafa532de7 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.14.34", + "version": "1.14.35", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a8c17f19f4..661201d2d9 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index b3e12fc253..bef0fee141 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 8a2ba85b02..448df66401 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 0c86216238..dcf52499d6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.14.34", + "version": "1.14.35", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index 8187602b09..a243a47078 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.14.34", + "version": "1.14.35", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 43f07930ef..22d8adc54b 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.14.34", + "version": "1.14.35", "publisher": "sst-dev", "repository": { "type": "git", From ca2411d332f4f7a98f44aa974a1b9d992d27dc8f Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 5 May 2026 11:05:53 +1000 Subject: [PATCH 154/178] Run UI unit tests in CI (#25792) --- packages/ui/package.json | 2 ++ .../ui/src/components/session-diff.test.ts | 15 ++++++++ packages/ui/src/components/session-diff.ts | 34 +++++++++++++++---- turbo.json | 9 +++++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index dcf52499d6..1bc70c15ab 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -25,6 +25,8 @@ }, "scripts": { "typecheck": "tsgo --noEmit", + "test": "bun test src", + "test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "dev": "vite", "generate:tailwind": "bun run script/tailwind.ts" }, diff --git a/packages/ui/src/components/session-diff.test.ts b/packages/ui/src/components/session-diff.test.ts index edaa15b84b..172fe8d6c2 100644 --- a/packages/ui/src/components/session-diff.test.ts +++ b/packages/ui/src/components/session-diff.test.ts @@ -19,6 +19,21 @@ describe("session diff", () => { expect(text(view, "additions")).toBe("one\nthree\n") }) + test("keeps missing final newlines from unified patches", () => { + const diff = { + file: "a.ts", + patch: + "Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n\\ No newline at end of file\n+three\n\\ No newline at end of file\n", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const view = normalize(diff) + + expect(text(view, "deletions")).toBe("one\ntwo") + expect(text(view, "additions")).toBe("one\nthree") + }) + test("converts legacy content into a patch", () => { const diff = { file: "a.ts", diff --git a/packages/ui/src/components/session-diff.ts b/packages/ui/src/components/session-diff.ts index 2da8c61a76..bd6bed88d8 100644 --- a/packages/ui/src/components/session-diff.ts +++ b/packages/ui/src/components/session-diff.ts @@ -29,24 +29,44 @@ function patch(diff: ReviewDiff) { if (typeof diff.patch === "string") { try { const [patch] = parsePatch(diff.patch) - const beforeLines = [] - const afterLines = [] + const beforeLines: Array<{ text: string; newline: boolean }> = [] + const afterLines: Array<{ text: string; newline: boolean }> = [] + let previous: "-" | "+" | " " | undefined for (const hunk of patch.hunks) { for (const line of hunk.lines) { + if (line.startsWith("\\")) { + if (previous === "-" || previous === " ") { + const before = beforeLines.at(-1) + if (before) before.newline = false + } + if (previous === "+" || previous === " ") { + const after = afterLines.at(-1) + if (after) after.newline = false + } + continue + } + if (line.startsWith("-")) { - beforeLines.push(line.slice(1)) + beforeLines.push({ text: line.slice(1), newline: true }) + previous = "-" } else if (line.startsWith("+")) { - afterLines.push(line.slice(1)) + afterLines.push({ text: line.slice(1), newline: true }) + previous = "+" } else { // context line (starts with ' ') - beforeLines.push(line.slice(1)) - afterLines.push(line.slice(1)) + beforeLines.push({ text: line.slice(1), newline: true }) + afterLines.push({ text: line.slice(1), newline: true }) + previous = " " } } } - return { before: beforeLines.join("\n"), after: afterLines.join("\n"), patch: diff.patch } + return { + before: beforeLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + after: afterLines.map((line) => line.text + (line.newline ? "\n" : "")).join(""), + patch: diff.patch, + } } catch { return { before: "", after: "", patch: diff.patch } } diff --git a/turbo.json b/turbo.json index 28c2fa2de0..0183fabca4 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,15 @@ "dependsOn": ["^build"], "outputs": [".artifacts/unit/junit.xml"], "passThroughEnv": ["*"] + }, + "@opencode-ai/ui#test": { + "dependsOn": ["^build"], + "outputs": [] + }, + "@opencode-ai/ui#test:ci": { + "dependsOn": ["^build"], + "outputs": [".artifacts/unit/junit.xml"], + "passThroughEnv": ["*"] } } } From 84afd2bef8d114b41a6cb9b38074ea5cb4c6d4f9 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 5 May 2026 09:19:13 +0800 Subject: [PATCH 155/178] update: normalize download asset names to match new naming convention (#25796) --- .../app/src/routes/download/[channel]/[platform].ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/console/app/src/routes/download/[channel]/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts index b486acb99d..4ae8e2465f 100644 --- a/packages/console/app/src/routes/download/[channel]/[platform].ts +++ b/packages/console/app/src/routes/download/[channel]/[platform].ts @@ -2,11 +2,11 @@ import type { APIEvent } from "@solidjs/start" import type { DownloadPlatform } from "../types" const prodAssetNames: Record = { - "darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg", - "darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg", - "windows-x64-nsis": "opencode-desktop-windows-x64.exe", + "darwin-aarch64-dmg": "opencode-desktop-mac-arm64.dmg", + "darwin-x64-dmg": "opencode-desktop-mac-x64.dmg", + "windows-x64-nsis": "opencode-desktop-win-x64.exe", "linux-x64-deb": "opencode-desktop-linux-amd64.deb", - "linux-x64-appimage": "opencode-desktop-linux-amd64.AppImage", + "linux-x64-appimage": "opencode-desktop-linux-x86_64.AppImage", "linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm", } satisfies Record From 22a4a9df8b98f998f526df983393df885388d569 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 4 May 2026 21:28:38 -0400 Subject: [PATCH 156/178] feat(core): session warping (#25768) --- .../migration.sql | 1 + .../snapshot.json | 1429 +++++++++++++++++ packages/opencode/script/httpapi-exercise.ts | 4 +- .../cmd/tui/component/dialog-session-list.tsx | 120 +- .../tui/component/dialog-workspace-create.tsx | 278 ++-- .../cli/cmd/tui/component/prompt/index.tsx | 357 ++-- .../cli/cmd/tui/component/workspace-label.tsx | 19 + .../cli/cmd/tui/routes/session/sidebar.tsx | 29 +- .../src/cli/cmd/tui/ui/dialog-select.tsx | 53 +- .../opencode/src/control-plane/workspace.ts | 281 ++-- .../src/server/routes/control/workspace.ts | 74 +- .../routes/instance/httpapi/groups/sync.ts | 16 + .../instance/httpapi/groups/workspace.ts | 29 +- .../routes/instance/httpapi/handlers/sync.ts | 24 +- .../instance/httpapi/handlers/workspace.ts | 15 +- .../src/server/routes/instance/index.ts | 2 +- .../src/server/routes/instance/sync.ts | 47 + packages/opencode/src/sync/event.sql.ts | 1 + packages/opencode/src/sync/index.ts | 34 +- .../test/control-plane/workspace.test.ts | 506 ++---- .../test/server/httpapi-workspace.test.ts | 19 +- packages/opencode/test/sync/index.test.ts | 73 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 266 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 631 +++++++- packages/sdk/openapi.json | 813 +++++++++- 25 files changed, 4029 insertions(+), 1092 deletions(-) create mode 100644 packages/opencode/migration/20260504145000_add_sync_owner/migration.sql create mode 100644 packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json create mode 100644 packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql b/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql new file mode 100644 index 0000000000..3bdf2b85e9 --- /dev/null +++ b/packages/opencode/migration/20260504145000_add_sync_owner/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `event_sequence` ADD `owner_id` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json new file mode 100644 index 0000000000..4f6ebe00c0 --- /dev/null +++ b/packages/opencode/migration/20260504145000_add_sync_owner/snapshot.json @@ -0,0 +1,1429 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "27114226-085b-421a-9a40-29b88747e29a", + "prevIds": ["aaa2ebeb-caa4-478d-8365-4fc595d16856"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_entry", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_entry_session_id_session_id_fk", + "entityType": "fks", + "table": "session_entry" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_entry_pk", + "table": "session_entry", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_type_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_time_created_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/script/httpapi-exercise.ts b/packages/opencode/script/httpapi-exercise.ts index 9755cf4017..771e1e417e 100644 --- a/packages/opencode/script/httpapi-exercise.ts +++ b/packages/opencode/script/httpapi-exercise.ts @@ -776,9 +776,9 @@ const scenarios: Scenario[] = [ })) .status(200), http - .post("/experimental/workspace/{id}/session-restore", "experimental.workspace.sessionRestore") + .post("/experimental/workspace/warp", "experimental.workspace.warp") .at((ctx) => ({ - path: route("/experimental/workspace/{id}/session-restore", { id: "wrk_httpapi_missing" }), + path: "/experimental/workspace/warp", headers: ctx.headers(), body: {}, })) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 04c6b9945c..09d952ef81 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -2,7 +2,7 @@ import { useDialog } from "@tui/ui/dialog" import { DialogSelect } from "@tui/ui/dialog-select" import { useRoute } from "@tui/context/route" import { useSync } from "@tui/context/sync" -import { createMemo, createResource, createSignal, onMount } from "solid-js" +import { createMemo, createResource, createSignal, onMount, type JSX } from "solid-js" import { Locale } from "@/util/locale" import { useProject } from "@tui/context/project" import { useKeybind } from "../context/keybind" @@ -10,15 +10,13 @@ import { useTheme } from "../context/theme" import { useSDK } from "../context/sdk" import { Flag } from "@opencode-ai/core/flag/flag" import { DialogSessionRename } from "./dialog-session-rename" -import { Keybind } from "@/util/keybind" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create" +import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" import { Spinner } from "./spinner" import { errorMessage } from "@/util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" - -type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" +import { WorkspaceLabel } from "./workspace-label" export function DialogSessionList() { const dialog = useDialog() @@ -44,26 +42,39 @@ export function DialogSessionList() { const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const sessions = createMemo(() => searchResults() ?? sync.data.session) - function createWorkspace() { - dialog.replace(() => ( - - openWorkspaceSession({ - dialog, - route, - sdk, - sync, - toast, - workspaceID, - }) - } - /> - )) - } - function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) const list = () => dialog.replace(() => ) + const warp = async (selection: WorkspaceSelection) => { + const workspaceID = await (async () => { + if (selection.type === "none") return null + if (selection.type === "existing") return selection.workspaceID + const result = await sdk.client.experimental.workspace + .create({ type: selection.workspaceType, branch: null }) + .catch(() => undefined) + const workspace = result?.data + if (!workspace) { + toast.show({ + message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, + variant: "error", + }) + return + } + await project.workspace.sync() + return workspace.id + })() + if (workspaceID === undefined) return + await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + workspaceID, + sessionID: session.id, + done: list, + }) + } dialog.replace(() => ( { - dialog.replace(() => ( - - restoreWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - workspaceID, - sessionID: session.id, - done: list, - }) - } - /> - )) + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warp(selection) + }, + }) return false }} /> @@ -124,30 +128,17 @@ export function DialogSessionList() { .map((x) => { const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined - let workspaceStatus: WorkspaceStatus | null = null - if (x.workspaceID) { - workspaceStatus = project.workspace.status(x.workspaceID) || "error" - } - - let footer = "" + let footer: JSX.Element | string = "" if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { if (x.workspaceID) { - let desc = "unknown" - if (workspace) { - desc = `${workspace.type}: ${workspace.name}` - } - - footer = ( - <> - {desc}{" "} - - ● - - + footer = workspace ? ( + + ) : ( + ) } } else { @@ -250,15 +241,6 @@ export function DialogSessionList() { dialog.replace(() => ) }, }, - { - keybind: Keybind.parse("ctrl+w")[0], - title: "new workspace", - side: "right", - disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - onTrigger: () => { - createWorkspace() - }, - }, ]} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index 0aa61c313a..e2af0d63e1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -1,11 +1,9 @@ -import { createOpencodeClient } from "@opencode-ai/sdk/v2" +import type { Workspace } from "@opencode-ai/sdk/v2" import { useDialog } from "@tui/ui/dialog" -import { DialogSelect } from "@tui/ui/dialog-select" -import { useRoute } from "@tui/context/route" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" import { useSync } from "@tui/context/sync" import { useProject } from "@tui/context/project" import { createMemo, createSignal, onMount } from "solid-js" -import { setTimeout as sleep } from "node:timers/promises" import { errorMessage } from "@/util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" @@ -16,184 +14,212 @@ type Adapter = { description: string } -function scoped(sdk: ReturnType, sync: ReturnType, workspaceID: string) { - return createOpencodeClient({ - baseUrl: sdk.url, - fetch: sdk.fetch, - directory: sync.path.directory || sdk.directory, - experimental_workspaceID: workspaceID, +export type WorkspaceSelection = + | { + type: "none" + } + | { + type: "new" + workspaceType: string + workspaceName: string + } + | { + type: "existing" + workspaceID: string + workspaceType: string + workspaceName: string + } + +type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } +type ExistingWorkspaceSelectValue = { workspace: Workspace } + +async function loadWorkspaceAdapters(input: { + sdk: ReturnType + sync: ReturnType + toast: ReturnType +}) { + const dir = input.sync.path.directory || input.sdk.directory + const url = new URL("/experimental/workspace/adapter", input.sdk.url) + if (dir) url.searchParams.set("directory", dir) + const res = await input.sdk + .fetch(url) + .then((x) => x.json() as Promise) + .catch(() => undefined) + if (res) return res + input.toast.show({ + message: "Failed to load workspace adapters", + variant: "error", }) } -export async function openWorkspaceSession(input: { +export async function openWorkspaceSelect(input: { dialog: ReturnType - route: ReturnType sdk: ReturnType sync: ReturnType toast: ReturnType - workspaceID: string + onSelect: (selection: WorkspaceSelection) => Promise | void }) { - const client = scoped(input.sdk, input.sync, input.workspaceID) - - while (true) { - const result = await client.session.create({ workspace: input.workspaceID }).catch(() => undefined) - if (!result) { - input.toast.show({ - message: "Failed to create workspace session", - variant: "error", - }) - return - } - if (result.response?.status && result.response.status >= 500 && result.response.status < 600) { - await sleep(1000) - continue - } - if (!result.data) { - input.toast.show({ - message: "Failed to create workspace session", - variant: "error", - }) - return - } - - input.route.navigate({ - type: "session", - sessionID: result.data.id, - }) - input.dialog.clear() - return - } + input.dialog.clear() + const adapters = await loadWorkspaceAdapters(input) + if (!adapters) return + input.dialog.replace(() => ) } -export async function restoreWorkspaceSession(input: { +export async function warpWorkspaceSession(input: { dialog: ReturnType sdk: ReturnType sync: ReturnType project: ReturnType toast: ReturnType - workspaceID: string + workspaceID: string | null sessionID: string done?: () => void -}) { +}): Promise { const result = await input.sdk.client.experimental.workspace - .sessionRestore({ id: input.workspaceID, sessionID: input.sessionID }) + .warp({ + id: input.workspaceID, + sessionID: input.sessionID, + }) .catch(() => undefined) if (!result?.data) { input.toast.show({ - message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`, + message: `Failed to warp session: ${errorMessage(result?.error ?? "no response")}`, variant: "error", }) - return + return false } input.project.workspace.set(input.workspaceID) await input.sync.bootstrap({ fatal: false }).catch(() => undefined) - await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]) + await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]) - input.toast.show({ - message: "Session restored into the new workspace", - variant: "success", - }) input.done?.() - if (input.done) return + if (input.done) return true input.dialog.clear() + return true } -export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise | void }) { +export function DialogWorkspaceSelect(props: { + adapters?: Adapter[] + onSelect: (selection: WorkspaceSelection) => Promise | void +}) { const dialog = useDialog() - const sync = useSync() const project = useProject() + const sync = useSync() const sdk = useSDK() const toast = useToast() - const [creating, setCreating] = createSignal() - const [adapters, setAdapters] = createSignal() + const [adapters, setAdapters] = createSignal(props.adapters) onMount(() => { dialog.setSize("medium") void (async () => { - const dir = sync.path.directory || sdk.directory - const url = new URL("/experimental/workspace/adapter", sdk.url) - if (dir) url.searchParams.set("directory", dir) - const res = await sdk - .fetch(url) - .then((x) => x.json() as Promise) - .catch(() => undefined) - if (!res) { - toast.show({ - message: "Failed to load workspace adapters", - variant: "error", - }) - return - } + if (adapters()) return + const res = await loadWorkspaceAdapters({ sdk, sync, toast }) + if (!res) return setAdapters(res) })() }) - const options = createMemo(() => { - const type = creating() - if (type) { - return [ - { - title: `Creating ${type} workspace...`, - value: "creating" as const, - description: "This can take a while for remote environments", - }, - ] - } + const options = createMemo[]>(() => { const list = adapters() - if (!list) { - return [ - { - title: "Loading workspaces...", - value: "loading" as const, - description: "Fetching available workspace adapters", + if (!list) return [] + const recent = sync.data.session + .toSorted((a, b) => b.time.updated - a.time.updated) + .flatMap((session) => (session.workspaceID ? [session.workspaceID] : [])) + .filter((workspaceID, index, list) => list.indexOf(workspaceID) === index) + .slice(0, 3) + .flatMap((workspaceID) => { + const workspace = project.workspace.get(workspaceID) + return workspace ? [workspace] : [] + }) + return [ + ...list.map((adapter) => ({ + title: adapter.name, + value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, + description: adapter.description, + category: "New workspace", + })), + { + title: "None", + value: { type: "none" as const }, + description: "Use the local project", + category: "Choose workspace", + }, + ...recent.map((workspace: Workspace) => ({ + title: workspace.name, + description: `(${workspace.type})`, + value: { + type: "existing" as const, + workspaceID: workspace.id, + workspaceType: workspace.type, + workspaceName: workspace.name, }, - ] - } - return list.map((item) => ({ - title: item.name, - value: item.type, - description: item.description, - })) + category: "Choose workspace", + })), + { + title: "View all workspaces", + value: { type: "existing-list" as const }, + description: "Choose from all workspaces", + category: "Choose workspace", + }, + ] }) - const create = async (type: string) => { - if (creating()) return - setCreating(type) - - const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => { - toast.show({ - message: "Creating workspace failed", - variant: "error", - }) - return undefined - }) + if (!adapters()) return null + return ( + + title="Warp" + skipFilter={true} + renderFilter={false} + options={options()} + onSelect={(option) => { + if (!option.value) return + if (option.value.type === "none") { + void props.onSelect(option.value) + return + } + if (option.value.type === "new") { + void props.onSelect(option.value) + return + } + if (option.value.type === "existing") { + void props.onSelect(option.value) + return + } + + dialog.replace(() => ) + }} + /> + ) +} - const workspace = result?.data - if (!workspace) { - setCreating(undefined) - toast.show({ - message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`, - variant: "error", - }) - return - } +function DialogExistingWorkspaceSelect(props: { onSelect: (selection: WorkspaceSelection) => Promise | void }) { + const project = useProject() - await project.workspace.sync() - await props.onSelect(workspace.id) - setCreating(undefined) - } + const options = createMemo[]>(() => + project.workspace + .list() + .filter((workspace) => project.workspace.status(workspace.id) === "connected") + .map((workspace: Workspace) => ({ + title: workspace.name, + description: `(${workspace.type})`, + value: { workspace }, + })), + ) return ( - + title="Existing Workspace" options={options()} onSelect={(option) => { - if (option.value === "creating" || option.value === "loading") return - void create(option.value) + void props.onSelect({ + type: "existing", + workspaceID: option.value.workspace.id, + workspaceType: option.value.workspace.type, + workspaceName: option.value.workspace.name, + }) }} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index a6ba797f33..74332c77be 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -7,6 +7,7 @@ import { Filesystem } from "@/util/filesystem" import { useLocal } from "@tui/context/local" import { tint, useTheme } from "@tui/context/theme" import { EmptyBorder, SplitBorder } from "@tui/component/border" +import { Spinner } from "@tui/component/spinner" import { useSDK } from "@tui/context/sdk" import { useRoute } from "@tui/context/route" import { useProject } from "@tui/context/project" @@ -41,9 +42,11 @@ import { useKV } from "../../context/kv" import { createFadeIn } from "../../util/signal" import { useTextareaKeybindings } from "../textarea-keybindings" import { DialogSkill } from "../dialog-skill" -import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create" +import { openWorkspaceSelect, warpWorkspaceSession, type WorkspaceSelection } from "../dialog-workspace-create" import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable" import { useArgs } from "@tui/context/args" +import { Flag } from "@opencode-ai/core/flag/flag" +import { WorkspaceLabel, type WorkspaceStatus } from "../workspace-label" export type PromptProps = { sessionID?: string @@ -173,9 +176,92 @@ export function Prompt(props: PromptProps) { const [editorContextHover, setEditorContextHover] = createSignal(false) let lastSubmittedEditorSelectionKey: string | undefined const [auto, setAuto] = createSignal() + const [workspaceSelection, setWorkspaceSelection] = createSignal() + const [workspaceCreating, setWorkspaceCreating] = createSignal(false) + const [workspaceCreatingDots, setWorkspaceCreatingDots] = createSignal(3) + const [warpNotice, setWarpNotice] = createSignal() const currentProviderLabel = createMemo(() => local.model.parsed().provider) const hasRightContent = createMemo(() => Boolean(props.right)) + function selectWorkspace(selection: WorkspaceSelection | undefined) { + setWorkspaceSelection(selection) + } + + function setCreatingWorkspace(creating: boolean) { + setWorkspaceCreating(creating) + } + + function showWarpNotice(name: string) { + setWarpNotice(`Warped to ${name}`) + setTimeout(() => setWarpNotice(undefined), 4000) + } + + async function createWorkspace(selection: Extract) { + setCreatingWorkspace(true) + const result = await sdk.client.experimental.workspace + .create({ type: selection.workspaceType, branch: null }) + .catch(() => undefined) + if (result == undefined || result.error || !result.data) { + selectWorkspace(undefined) + setCreatingWorkspace(false) + toast.show({ + message: "Creating workspace failed", + variant: "error", + }) + return + } + + await project.workspace.sync() + const workspace = result.data + selectWorkspace({ + type: "existing", + workspaceID: workspace.id, + workspaceType: workspace.type, + workspaceName: workspace.name, + }) + setCreatingWorkspace(false) + return workspace + } + + async function warpSession(selection: WorkspaceSelection) { + if (!props.sessionID) { + selectWorkspace(selection) + dialog.clear() + if (selection.type === "new") void createWorkspace(selection) + return + } + selectWorkspace(selection) + dialog.clear() + + const workspace = + selection.type === "none" + ? { id: null, name: "local project" } + : selection.type === "existing" + ? { id: selection.workspaceID, name: selection.workspaceName } + : await createWorkspace(selection) + if (!workspace) return + + const warped = await warpWorkspaceSession({ + dialog, + sdk, + sync, + project, + toast, + workspaceID: workspace.id, + sessionID: props.sessionID, + }) + if (warped) showWarpNotice(workspace.name) + } + + createEffect(() => { + if (!workspaceCreating()) { + setWorkspaceCreatingDots(3) + return + } + const timer = setInterval(() => setWorkspaceCreatingDots((dots) => (dots % 3) + 1), 1000) + onCleanup(() => clearInterval(timer)) + }) + function promptModelWarning() { toast.show({ variant: "warning", @@ -213,6 +299,7 @@ export function Prompt(props: PromptProps) { }) createEffect(() => { + if (!input || input.isDestroyed) return if (props.disabled) input.cursorColor = theme.backgroundElement if (!props.disabled) input.cursorColor = theme.text }) @@ -489,6 +576,27 @@ export function Prompt(props: PromptProps) { )) }, }, + { + title: "Warp", + description: "Change the workspace for the session", + value: "workspace.set", + category: "Session", + enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, + slash: { + name: "warp", + }, + onSelect: (dialog) => { + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + }, + }, ] }) @@ -699,6 +807,8 @@ export function Prompt(props: PromptProps) { ]) async function submit() { + setWarpNotice(undefined) + // IME: double-defer may fire before onContentChange flushes the last // composed character (e.g. Korean hangul) to the store, so read // plainText directly and sync before any downstream reads. @@ -707,6 +817,7 @@ export function Prompt(props: PromptProps) { syncExtmarksWithPromptParts() } if (props.disabled) return false + if (workspaceCreating()) return false if (autocomplete?.visible) return false if (!store.prompt.input) return false const agent = local.agent.current() @@ -729,21 +840,16 @@ export function Prompt(props: PromptProps) { dialog.replace(() => ( { - dialog.replace(() => ( - - restoreWorkspaceSession({ - dialog, - sdk, - sync, - project, - toast, - workspaceID: nextWorkspaceID, - sessionID: props.sessionID!, - }) - } - /> - )) + void openWorkspaceSelect({ + dialog, + sdk, + sync, + toast, + onSelect: (selection) => { + void warpSession(selection) + }, + }) + return false }} /> )) @@ -753,6 +859,14 @@ export function Prompt(props: PromptProps) { const variant = local.model.variant.current() let sessionID = props.sessionID if (sessionID == null) { + const workspace = workspaceSelection() + const workspaceID = iife(() => { + if (!workspace) return undefined + if (workspace.type === "none") return undefined + if (workspace.type === "existing") return workspace.workspaceID + return undefined + }) + const res = await sdk.client.session.create({ workspace: props.workspaceID, agent: agent.name, @@ -1025,6 +1139,29 @@ export function Prompt(props: PromptProps) { return `Ask anything... "${list()[store.placeholder % list().length]}"` }) + const workspaceLabel = createMemo< + | { type: "new"; workspaceType: string } + | { type: "existing"; workspaceType: string; workspaceName: string; status?: WorkspaceStatus } + | undefined + >(() => { + const selected = workspaceSelection() + if (!selected) return + if (selected.type === "none") return + if (props.sessionID && !workspaceCreating()) return + if (selected.type === "new") { + return { + type: "new", + workspaceType: selected.workspaceType, + } + } + return { + type: "existing", + workspaceType: selected.workspaceType, + workspaceName: selected.workspaceName, + status: selected.type === "existing" ? "connected" : undefined, + } + }) + const spinnerDef = createMemo(() => { const agent = local.agent.current() const color = agent ? local.agent.color(agent.name) : theme.border @@ -1281,7 +1418,7 @@ export function Prompt(props: PromptProps) { }} onMouseDown={(r: MouseEvent) => r.target?.focus()} focusedBackgroundColor={theme.backgroundElement} - cursorColor={theme.text} + cursorColor={props.disabled ? theme.backgroundElement : theme.text} syntaxStyle={syntax()} /> @@ -1351,86 +1488,124 @@ export function Prompt(props: PromptProps) { /> - }> - - - - [⋯]}> - - - - - {(() => { - const retry = createMemo(() => { - const s = status() - if (s.type !== "retry") return - return s - }) - const message = createMemo(() => { - const r = retry() - if (!r) return - if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) - return "gemini is way too hot right now" - if (r.message.length > 80) return r.message.slice(0, 80) + "..." - return r.message - }) - const isTruncated = createMemo(() => { - const r = retry() - if (!r) return false - return r.message.length > 120 - }) - const [seconds, setSeconds] = createSignal(0) - onMount(() => { - const timer = setInterval(() => { - const next = retry()?.next - if (next) setSeconds(Math.round((next - Date.now()) / 1000)) - }, 1000) - - onCleanup(() => { - clearInterval(timer) + + + + + + [⋯]}> + + + + + {(() => { + const retry = createMemo(() => { + const s = status() + if (s.type !== "retry") return + return s }) - }) - const handleMessageClick = () => { - const r = retry() - if (!r) return - if (isTruncated()) { - void DialogAlert.show(dialog, "Retry Error", r.message) + const message = createMemo(() => { + const r = retry() + if (!r) return + if (r.message.includes("exceeded your current quota") && r.message.includes("gemini")) + return "gemini is way too hot right now" + if (r.message.length > 80) return r.message.slice(0, 80) + "..." + return r.message + }) + const isTruncated = createMemo(() => { + const r = retry() + if (!r) return false + return r.message.length > 120 + }) + const [seconds, setSeconds] = createSignal(0) + onMount(() => { + const timer = setInterval(() => { + const next = retry()?.next + if (next) setSeconds(Math.round((next - Date.now()) / 1000)) + }, 1000) + + onCleanup(() => { + clearInterval(timer) + }) + }) + const handleMessageClick = () => { + const r = retry() + if (!r) return + if (isTruncated()) { + void DialogAlert.show(dialog, "Retry Error", r.message) + } } - } - const retryText = () => { - const r = retry() - if (!r) return "" - const baseMessage = message() - const truncatedHint = isTruncated() ? " (click to expand)" : "" - const duration = formatDuration(seconds()) - const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` - return baseMessage + truncatedHint + retryInfo - } + const retryText = () => { + const r = retry() + if (!r) return "" + const baseMessage = message() + const truncatedHint = isTruncated() ? " (click to expand)" : "" + const duration = formatDuration(seconds()) + const retryInfo = ` [retrying ${duration ? `in ${duration} ` : ""}attempt #${r.attempt}]` + return baseMessage + truncatedHint + retryInfo + } - return ( - - - {retryText()} - - - ) - })()} + return ( + + + {retryText()} + + + ) + })()} + + 0 ? theme.primary : theme.text}> + esc{" "} + 0 ? theme.primary : theme.textMuted }}> + {store.interrupt > 0 ? "again to interrupt" : "interrupt"} + + - 0 ? theme.primary : theme.text}> - esc{" "} - 0 ? theme.primary : theme.textMuted }}> - {store.interrupt > 0 ? "again to interrupt" : "interrupt"} - - - - + + + {(notice) => ( + + {notice()} + + )} + + + {(workspace) => ( + + + + + + {(() => { + const item = workspace() + if (item.type === "new") { + if (workspaceCreating()) + return `Creating ${item.workspaceType}${".".repeat(workspaceCreatingDots())}` + return ( + <> + Workspace (new {item.workspaceType}) + + ) + } + return ( + <> + Workspace {item.workspaceName} + + ) + })()} + + + )} + + {props.hint ?? } + diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx new file mode 100644 index 0000000000..efdbf71587 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/workspace-label.tsx @@ -0,0 +1,19 @@ +import { useTheme } from "@tui/context/theme" + +export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" + +export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) { + const { theme } = useTheme() + const color = () => { + if (props.status === "connected") return theme.success + if (props.status === "error") return theme.error + return theme.textMuted + } + + return ( + <> + {props.icon ? : undefined} + {props.name} ({props.type}) + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 7adc4c1db1..0f9214092e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -7,6 +7,7 @@ import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/inst import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { getScrollAcceleration } from "../../util/scroll" +import { WorkspaceLabel } from "../../component/workspace-label" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const project = useProject() @@ -14,17 +15,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const { theme } = useTheme() const tuiConfig = useTuiConfig() const session = createMemo(() => sync.session.get(props.sessionID)) - const workspaceStatus = () => { + const workspace = () => { const workspaceID = session()?.workspaceID - if (!workspaceID) return "error" - return project.workspace.status(workspaceID) ?? "error" - } - const workspaceLabel = () => { - const workspaceID = session()?.workspaceID - if (!workspaceID) return "unknown" - const info = project.workspace.get(workspaceID) - if (!info) return "unknown" - return `${info.type}: ${info.name}` + if (!workspaceID) return + return project.workspace.get(workspaceID) } const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) @@ -67,8 +61,19 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - {" "} - {workspaceLabel()} + } + > + {(item) => ( + + )} + diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 4d68c44308..ef7d4bd3bb 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -23,6 +23,7 @@ export interface DialogSelectProps { onFilter?: (query: string) => void onSelect?: (option: DialogSelectOption) => void skipFilter?: boolean + renderFilter?: boolean keybind?: { keybind?: Keybind.Info title: string @@ -81,7 +82,7 @@ export function DialogSelect(props: DialogSelectProps) { let input: InputRenderable const filtered = createMemo(() => { - if (props.skipFilter) return props.options.filter((x) => x.disabled !== true) + if (props.skipFilter || props.renderFilter === false) return props.options.filter((x) => x.disabled !== true) const needle = store.filter.toLowerCase() const options = pipe( props.options, @@ -250,30 +251,32 @@ export function DialogSelect(props: DialogSelectProps) { esc - - { - batch(() => { - setStore("filter", e) - props.onFilter?.(e) - }) - }} - focusedBackgroundColor={theme.backgroundPanel} - cursorColor={theme.primary} - focusedTextColor={theme.textMuted} - ref={(r) => { - input = r - input.traits = { status: "FILTER" } - setTimeout(() => { - if (!input) return - if (input.isDestroyed) return - input.focus() - }, 1) - }} - placeholder={props.placeholder ?? "Search"} - placeholderColor={theme.textMuted} - /> - + + + { + batch(() => { + setStore("filter", e) + props.onFilter?.(e) + }) + }} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.primary} + focusedTextColor={theme.textMuted} + ref={(r) => { + input = r + input.traits = { status: "FILTER" } + setTimeout(() => { + if (!input) return + if (input.isDestroyed) return + input.focus() + }, 1) + }} + placeholder={props.placeholder ?? "Search"} + placeholderColor={theme.textMuted} + /> + + 0} diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 485cb2e925..fe651fe3e3 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,10 +1,11 @@ -import { Context, Effect, FiberMap, Layer, Schema, Stream } from "effect" +import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { Database } from "@/storage/db" import { asc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { Project } from "@/project/project" +import { Instance } from "@/project/instance" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Auth } from "@/auth" @@ -20,6 +21,7 @@ import { getAdapter } from "./adapters" import { type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceID } from "./schema" import { Session } from "@/session/session" +import { SessionPrompt } from "@/session/prompt" import { SessionTable } from "@/session/session.sql" import { SessionID } from "@/session/schema" import { errorData } from "@/util/error" @@ -38,13 +40,6 @@ export const ConnectionStatus = Schema.Struct({ }) export type ConnectionStatus = Schema.Schema.Type -const Restore = Schema.Struct({ - workspaceID: WorkspaceID, - sessionID: SessionID, - total: NonNegativeInt, - step: NonNegativeInt, -}) - export const Event = { Ready: BusEvent.define( "workspace.ready", @@ -58,7 +53,6 @@ export const Event = { message: Schema.String, }), ), - Restore: BusEvent.define("workspace.restore", Restore), Status: BusEvent.define("workspace.status", ConnectionStatus), } @@ -84,15 +78,15 @@ export const CreateInput = Schema.Struct({ type: Info.fields.type, branch: Info.fields.branch, projectID: ProjectID, - extra: Info.fields.extra, + extra: Schema.optional(Info.fields.extra), }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) export type CreateInput = Schema.Schema.Type -export const SessionRestoreInput = Schema.Struct({ - workspaceID: WorkspaceID, +export const SessionWarpInput = Schema.Struct({ + workspaceID: Schema.NullOr(WorkspaceID), sessionID: SessionID, }).pipe(withStatics((s) => ({ zod: effectZod(s), zodObject: zodObject(s) }))) -export type SessionRestoreInput = Schema.Schema.Type +export type SessionWarpInput = Schema.Schema.Type export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { message: Schema.String, @@ -116,8 +110,8 @@ export class SessionEventsNotFoundError extends Schema.TaggedErrorClass()( - "WorkspaceSessionRestoreHttpError", +export class SessionWarpHttpError extends Schema.TaggedErrorClass()( + "WorkspaceSessionWarpHttpError", { message: Schema.String, workspaceID: WorkspaceID, @@ -138,17 +132,17 @@ export class SyncAbortedError extends Schema.TaggedErrorClass( }) {} type CreateError = Auth.AuthError -type SessionRestoreError = +type SessionWarpError = | WorkspaceNotFoundError | SessionEventsNotFoundError - | SessionRestoreHttpError + | SessionWarpHttpError | HttpClientError.HttpClientError type WaitForSyncError = SyncTimeoutError | SyncAbortedError type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError export interface Interface { readonly create: (input: CreateInput) => Effect.Effect - readonly sessionRestore: (input: SessionRestoreInput) => Effect.Effect<{ total: number }, SessionRestoreError> + readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect readonly list: (project: Project.Info) => Effect.Effect readonly get: (id: WorkspaceID) => Effect.Effect readonly remove: (id: WorkspaceID) => Effect.Effect @@ -169,6 +163,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const auth = yield* Auth.Service const session = yield* Session.Service + const prompt = yield* SessionPrompt.Service const http = yield* HttpClient.HttpClient const sync = yield* SyncEvent.Service const connections = new Map() @@ -461,7 +456,7 @@ export const layer = Layer.effect( const id = WorkspaceID.ascending(input.id) const adapter = getAdapter(input.projectID, input.type) const config = yield* EffectBridge.fromPromise(() => - adapter.configure({ ...input, id, name: Slug.create(), directory: null }), + adapter.configure({ ...input, id, name: Slug.create(), directory: null, extra: input.extra ?? null }), ) const info: Info = { @@ -518,29 +513,93 @@ export const layer = Layer.effect( return info }) - const sessionRestore = Effect.fn("Workspace.sessionRestore")(function* (input: SessionRestoreInput) { + const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) { return yield* Effect.gen(function* () { - log.info("session restore requested", { + log.info("session warp requested", { workspaceID: input.workspaceID, sessionID: input.sessionID, }) - const space = yield* get(input.workspaceID) + const current = yield* db((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, input.sessionID)) + .get(), + ) + + if (current?.workspaceID) { + const previous = yield* get(current.workspaceID) + if (previous) { + const adapter = getAdapter(previous.projectID, previous.type) + const target = yield* EffectBridge.fromPromise(() => adapter.target(previous)) + + if (target.type === "remote") { + yield* syncHistory(previous, target.url, target.headers).pipe( + Effect.catch((error) => + Effect.sync(() => { + log.warn("session warp final source sync failed", { + workspaceID: previous.id, + sessionID: input.sessionID, + error: errorData(error), + }) + }), + ), + ) + } else { + yield* prompt.cancel(input.sessionID) + } + + // "claim" this session so any future events coming from + // the old workspace are ignored + SyncEvent.claim(input.sessionID, input.workspaceID ?? Instance.project.id) + } + } + + if (input.workspaceID === null) { + yield* Effect.sync(() => + SyncEvent.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: null, + }, + }), + ) + + log.info("session warp complete", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + target: "local", + }) + return + } + + const workspaceID = input.workspaceID + const space = yield* get(workspaceID) if (!space) return yield* new WorkspaceNotFoundError({ - message: `Workspace not found: ${input.workspaceID}`, - workspaceID: input.workspaceID, + message: `Workspace not found: ${workspaceID}`, + workspaceID, }) const adapter = getAdapter(space.projectID, space.type) const target = yield* EffectBridge.fromPromise(() => adapter.target(space)) - yield* sync.run(Session.Event.Updated, { - sessionID: input.sessionID, - info: { + if (target.type === "local") { + yield* sync.run(Session.Event.Updated, { + sessionID: input.sessionID, + info: { + workspaceID: input.workspaceID, + }, + }) + + log.info("session warp complete", { workspaceID: input.workspaceID, - }, - }) + sessionID: input.sessionID, + target: target.directory, + }) + return + } const rows = yield* db((db) => db @@ -562,130 +621,95 @@ export const layer = Layer.effect( sessionID: input.sessionID, }) - const size = 10 - // TODO: look into using effect APIs to process this in chunks - const sets = Array.from({ length: Math.ceil(rows.length / size) }, (_, i) => - rows.slice(i * size, (i + 1) * size), - ) - const total = sets.length + const batches = Iterable.chunksOf(rows, 10) + const total = Iterable.size(batches) - log.info("session restore prepared", { + log.info("session warp prepared", { workspaceID: input.workspaceID, sessionID: input.sessionID, - workspaceType: space.type, - directory: space.directory, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, + target: String(route(target.url, "/sync/replay")), events: rows.length, batches: total, first: rows[0]?.seq, last: rows.at(-1)?.seq, }) - yield* Effect.sync(() => - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: 0, - }, - }, - }), - ) - - for (const [i, events] of sets.entries()) { - log.info("session restore batch starting", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - first: events[0]?.seq, - last: events.at(-1)?.seq, - target: target.type === "remote" ? String(route(target.url, "/sync/replay")) : target.directory, - }) - - if (target.type === "local") { - yield* sync.replayAll(events) - log.info("session restore batch replayed locally", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - events: events.length, - }) - } else { - const url = route(target.url, "/sync/replay") - const res = yield* http.execute( - HttpClientRequest.post(url, { - headers: new Headers(target.headers), - body: HttpBody.jsonUnsafe({ - directory: space.directory ?? "", - events, + yield* Effect.forEach( + batches, + (events, i) => + Effect.gen(function* () { + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/replay"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ + directory: space.directory ?? "", + events, + }), }), - }), - ) + ) - if (res.status < 200 || res.status >= 300) { - const body = yield* res.text - log.error("session restore batch failed", { + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + log.error("session warp batch failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + step: i + 1, + total, + status: response.status, + body, + }) + return yield* new SessionWarpHttpError({ + message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + } + + log.info("session warp batch posted", { workspaceID: input.workspaceID, sessionID: input.sessionID, step: i + 1, total, - status: res.status, - body, + status: response.status, }) - return yield* new SessionRestoreHttpError({ - message: `Failed to replay session ${input.sessionID} into workspace ${input.workspaceID}: HTTP ${res.status} ${body}`, - workspaceID: input.workspaceID, - sessionID: input.sessionID, - status: res.status, - body, - }) - } - - log.info("session restore batch posted", { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - step: i + 1, - total, - status: res.status, - }) - } - - yield* Effect.sync(() => - GlobalBus.emit("event", { - directory: "global", - workspace: input.workspaceID, - payload: { - type: Event.Restore.type, - properties: { - workspaceID: input.workspaceID, - sessionID: input.sessionID, - total, - step: i + 1, - }, - }, }), - ) + { discard: true }, + ) + + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/steal"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }), + }), + ) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + log.error("session warp steal failed", { + workspaceID: input.workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + return yield* new SessionWarpHttpError({ + message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) } - log.info("session restore complete", { + log.info("session warp complete", { workspaceID: input.workspaceID, sessionID: input.sessionID, batches: total, }) - - return { total } }).pipe( Effect.tapError((err) => Effect.sync(() => - log.error("session restore failed", { + log.error("session warp failed", { workspaceID: input.workspaceID, sessionID: input.sessionID, error: errorData(err), @@ -814,7 +838,7 @@ export const layer = Layer.effect( return Service.of({ create, - sessionRestore, + sessionWarp, list, get, remove, @@ -830,6 +854,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Session.defaultLayer), Layer.provide(SyncEvent.defaultLayer), + Layer.provide(SessionPrompt.defaultLayer), Layer.provide(FetchHttpClient.layer), ) diff --git a/packages/opencode/src/server/routes/control/workspace.ts b/packages/opencode/src/server/routes/control/workspace.ts index 21a7810ce1..788aef3176 100644 --- a/packages/opencode/src/server/routes/control/workspace.ts +++ b/packages/opencode/src/server/routes/control/workspace.ts @@ -10,10 +10,6 @@ import { zodObject } from "@/util/effect-zod" import { Instance } from "@/project/instance" import { errors } from "../../error" import { lazy } from "@/util/lazy" -import * as Log from "@opencode-ai/core/util/log" -import { errorData } from "@/util/error" - -const log = Log.create({ service: "server.workspace" }) export const WorkspaceRoutes = lazy(() => new Hono() @@ -151,60 +147,36 @@ export const WorkspaceRoutes = lazy(() => }, ) .post( - "/:id/session-restore", + "/warp", describeRoute({ - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", - operationId: "experimental.workspace.sessionRestore", + summary: "Warp session into workspace", + description: "Move a session's sync history into the target workspace, or detach it to the local project.", + operationId: "experimental.workspace.warp", responses: { - 200: { - description: "Session replay started", - content: { - "application/json": { - schema: resolver( - z.object({ - total: z.number().int().min(0), - }), - ), - }, - }, + 204: { + description: "Session warped", }, ...errors(400), }, }), - validator("param", z.object({ id: zodObject(Workspace.Info).shape.id })), - validator("json", Workspace.SessionRestoreInput.zodObject.omit({ workspaceID: true })), + validator( + "json", + z.object({ + id: zodObject(Workspace.Info).shape.id.nullable(), + sessionID: Workspace.SessionWarpInput.zodObject.shape.sessionID, + }), + ), async (c) => { - const { id } = c.req.valid("param") - const body = c.req.valid("json") as Omit - log.info("session restore route requested", { - workspaceID: id, - sessionID: body.sessionID, - directory: Instance.directory, - }) - try { - const result = await AppRuntime.runPromise( - Workspace.Service.use((svc) => - svc.sessionRestore({ - workspaceID: id, - ...body, - }), - ), - ) - log.info("session restore route complete", { - workspaceID: id, - sessionID: body.sessionID, - total: result.total, - }) - return c.json(result) - } catch (err) { - log.error("session restore route failed", { - workspaceID: id, - sessionID: body.sessionID, - error: errorData(err), - }) - throw err - } + const body = c.req.valid("json") + await AppRuntime.runPromise( + Workspace.Service.use((workspace) => + workspace.sessionWarp({ + workspaceID: body.id, + sessionID: body.sessionID, + }), + ), + ) + return c.body(null, 204) }, ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index 58d30b4c78..442e656554 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,4 +1,5 @@ import { NonNegativeInt } from "@/util/schema" +import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" @@ -21,6 +22,9 @@ export const ReplayPayload = Schema.Struct({ export const ReplayResponse = Schema.Struct({ sessionID: Schema.String, }) +export const SessionPayload = Schema.Struct({ + sessionID: SessionID, +}) export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) export const HistoryEvent = Schema.Struct({ id: Schema.String, @@ -33,6 +37,7 @@ export const HistoryEvent = Schema.Struct({ export const SyncPaths = { start: `${root}/start`, replay: `${root}/replay`, + steal: `${root}/steal`, history: `${root}/history`, } as const @@ -60,6 +65,17 @@ export const SyncApi = HttpApi.make("sync") description: "Validate and replay a complete sync event history.", }), ), + HttpApiEndpoint.post("steal", SyncPaths.steal, { + payload: SessionPayload, + success: described(SessionPayload, "Session stolen into workspace"), + error: HttpApiError.BadRequest, + }).annotateMerge( + OpenApi.annotations({ + identifier: "sync.steal", + summary: "Steal session into workspace", + description: "Update a session to belong to the current workspace through the sync event system.", + }), + ), HttpApiEndpoint.post("history", SyncPaths.history, { payload: HistoryPayload, success: described(Schema.Array(HistoryEvent), "Sync events"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 08e9e044bb..f197ab9765 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -1,21 +1,17 @@ import { Workspace } from "@/control-plane/workspace" import { WorkspaceAdapterEntry } from "@/control-plane/types" -import { NonNegativeInt } from "@/util/schema" import { Schema, Struct } from "effect" -import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing" import { described } from "./metadata" const root = "/experimental/workspace" -export const CreatePayload = Schema.Struct({ - ...Struct.omit(Workspace.CreateInput.fields, ["projectID", "extra"]), - extra: Schema.optional(Workspace.CreateInput.fields.extra), -}) -export const SessionRestorePayload = Schema.Struct(Struct.omit(Workspace.SessionRestoreInput.fields, ["workspaceID"])) -export const SessionRestoreResponse = Schema.Struct({ - total: NonNegativeInt, +export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) +export const WarpPayload = Schema.Struct({ + id: Schema.NullOr(Workspace.Info.fields.id), + sessionID: Workspace.SessionWarpInput.fields.sessionID, }) export const WorkspacePaths = { @@ -23,7 +19,7 @@ export const WorkspacePaths = { list: root, status: `${root}/status`, remove: `${root}/:id`, - sessionRestore: `${root}/:id/session-restore`, + warp: `${root}/warp`, } as const export const WorkspaceApi = HttpApi.make("workspace") @@ -79,16 +75,15 @@ export const WorkspaceApi = HttpApi.make("workspace") description: "Remove an existing workspace.", }), ), - HttpApiEndpoint.post("sessionRestore", WorkspacePaths.sessionRestore, { - params: { id: Workspace.Info.fields.id }, - payload: SessionRestorePayload, - success: described(SessionRestoreResponse, "Session replay started"), + HttpApiEndpoint.post("warp", WorkspacePaths.warp, { + payload: WarpPayload, + success: described(HttpApiSchema.NoContent, "Session warped"), error: HttpApiError.BadRequest, }).annotateMerge( OpenApi.annotations({ - identifier: "experimental.workspace.sessionRestore", - summary: "Restore session into workspace", - description: "Replay a session's sync events into the target workspace in batches.", + identifier: "experimental.workspace.warp", + summary: "Warp session into workspace", + description: "Move a session's sync history into the target workspace, or detach it to the local project.", }), ), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index f4a2f315cd..152d22f98e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -1,5 +1,6 @@ import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" +import { Session } from "@/session/session" import { Database } from "@/storage/db" import { SyncEvent } from "@/sync" import { EventTable } from "@/sync/event.sql" @@ -12,7 +13,7 @@ import { or } from "drizzle-orm" import { Effect, Scope } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { HistoryPayload, ReplayPayload } from "../groups/sync" +import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync" import * as Log from "@opencode-ai/core/util/log" const log = Log.create({ service: "server.sync" }) @@ -56,6 +57,25 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl return { sessionID: source } }) + const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) { + const workspaceID = yield* InstanceState.workspaceID + if (!workspaceID) throw new Error("Cannot steal session without workspace context") + + yield* sync.run(Session.Event.Updated, { + sessionID: ctx.payload.sessionID, + info: { + workspaceID, + }, + }) + + log.info("sync session stolen", { + sessionID: ctx.payload.sessionID, + workspaceID, + }) + + return { sessionID: ctx.payload.sessionID } + }) + const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { const exclude = Object.entries(ctx.payload) return Database.use((db) => @@ -72,6 +92,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl ) }) - return handlers.handle("start", start).handle("replay", replay).handle("history", history) + return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 570f355e57..b415943a62 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -4,7 +4,7 @@ import * as InstanceState from "@/effect/instance-state" import { Effect } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { CreatePayload, SessionRestorePayload } from "../groups/workspace" +import { CreatePayload, WarpPayload } from "../groups/workspace" export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => Effect.gen(function* () { @@ -39,13 +39,10 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac return yield* workspace.remove(ctx.params.id) }) - const sessionRestore = Effect.fn("WorkspaceHttpApi.sessionRestore")(function* (ctx: { - params: { id: Workspace.Info["id"] } - payload: typeof SessionRestorePayload.Type - }) { - return yield* workspace - .sessionRestore({ - workspaceID: ctx.params.id, + const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) { + yield* workspace + .sessionWarp({ + workspaceID: ctx.payload.id, sessionID: ctx.payload.sessionID, }) .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) @@ -57,6 +54,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .handle("create", create) .handle("status", status) .handle("remove", remove) - .handle("sessionRestore", sessionRestore) + .handle("warp", warp) }), ) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 89b5641e58..71662dea90 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -155,7 +155,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context)) app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context)) app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context)) - app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context)) + app.post(WorkspacePaths.warp, (c) => handler(c.req.raw, context)) } return app diff --git a/packages/opencode/src/server/routes/instance/sync.ts b/packages/opencode/src/server/routes/instance/sync.ts index b7bf413d4e..9894d8c8ee 100644 --- a/packages/opencode/src/server/routes/instance/sync.ts +++ b/packages/opencode/src/server/routes/instance/sync.ts @@ -16,6 +16,9 @@ import { Workspace } from "@/control-plane/workspace" import { AppRuntime } from "@/effect/app-runtime" import { Instance } from "@/project/instance" import { errors } from "../../error" +import { Session } from "@/session/session" +import { WorkspaceContext } from "@/control-plane/workspace-context" +import { SessionID } from "@/session/schema" const ReplayEvent = z.object({ id: z.string(), @@ -24,6 +27,9 @@ const ReplayEvent = z.object({ type: z.string(), data: z.record(z.string(), z.unknown()), }) +const SessionPayload = z.object({ + sessionID: SessionID.zod, +}) const log = Log.create({ service: "server.sync" }) @@ -108,6 +114,47 @@ export const SyncRoutes = lazy(() => }) }, ) + .post( + "/steal", + describeRoute({ + summary: "Steal session into workspace", + description: "Update a session to belong to the current workspace through the sync event system.", + operationId: "sync.steal", + responses: { + 200: { + description: "Session stolen into workspace", + content: { + "application/json": { + schema: resolver(SessionPayload), + }, + }, + }, + ...errors(400), + }, + }), + validator("json", SessionPayload), + async (c) => { + const body = c.req.valid("json") + const workspaceID = WorkspaceContext.workspaceID + if (!workspaceID) throw new Error("Cannot steal session without workspace context") + + SyncEvent.run(Session.Event.Updated, { + sessionID: body.sessionID, + info: { + workspaceID, + }, + }) + + log.info("sync session stolen", { + sessionID: body.sessionID, + workspaceID, + }) + + return c.json({ + sessionID: body.sessionID, + }) + }, + ) .post( "/history", describeRoute({ diff --git a/packages/opencode/src/sync/event.sql.ts b/packages/opencode/src/sync/event.sql.ts index b51b5a5dfe..547a80f0f3 100644 --- a/packages/opencode/src/sync/event.sql.ts +++ b/packages/opencode/src/sync/event.sql.ts @@ -3,6 +3,7 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" export const EventSequenceTable = sqliteTable("event_sequence", { aggregate_id: text().notNull().primaryKey(), seq: integer().notNull(), + owner_id: text(), }) export const EventTable = sqliteTable("event", { diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index 2654767e9a..62b30ccf9a 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -59,8 +59,11 @@ export interface Interface { data: Event["data"], options?: { publish?: boolean }, ) => Effect.Effect - readonly replay: (event: SerializedEvent, options?: { publish: boolean }) => Effect.Effect - readonly replayAll: (events: SerializedEvent[], options?: { publish: boolean }) => Effect.Effect + readonly replay: (event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) => Effect.Effect + readonly replayAll: ( + events: SerializedEvent[], + options?: { publish: boolean; ownerID?: string }, + ) => Effect.Effect readonly remove: (aggregateID: string) => Effect.Effect } @@ -76,7 +79,7 @@ export const layer = Layer.effect(Service)( const row = Database.use((db) => db - .select({ seq: EventSequenceTable.seq }) + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) .from(EventSequenceTable) .where(eq(EventSequenceTable.aggregate_id, event.aggregateID)) .get(), @@ -85,6 +88,10 @@ export const layer = Layer.effect(Service)( const latest = row?.seq ?? -1 if (event.seq <= latest) return + if (row?.ownerID && row.ownerID !== options?.ownerID) { + return + } + const expected = latest + 1 if (event.seq !== expected) { throw new Error( @@ -99,7 +106,7 @@ export const layer = Layer.effect(Service)( workspace: yield* InstanceState.workspaceID, } : undefined - process(def, event, { publish, context }) + process(def, event, { publish, context, ownerID: options?.ownerID }) }) const replayAll: Interface["replayAll"] = Effect.fn("SyncEvent.replayAll")(function* (events, options) { @@ -263,7 +270,7 @@ export function project( function process( def: Def, event: Event, - options: { publish: boolean; context?: PublishContext }, + options: { publish: boolean; context?: PublishContext; ownerID?: string }, ) { if (projectors == null) { throw new Error("No projectors available. Call `SyncEvent.init` to install projectors") @@ -274,8 +281,6 @@ function process( throw new Error(`Projector not found for event: ${def.type}`) } - // idempotent: need to ignore any events already logged - Database.transaction((tx) => { projector(tx, event.data, event) @@ -284,6 +289,7 @@ function process( .values({ aggregate_id: event.aggregateID, seq: event.seq, + owner_id: options?.ownerID, }) .onConflictDoUpdate({ target: EventSequenceTable.aggregate_id, @@ -332,11 +338,11 @@ function process( }) } -export function replay(event: SerializedEvent, options?: { publish: boolean }) { +export function replay(event: SerializedEvent, options?: { publish: boolean; ownerID?: string }) { return runtime.runSync((sync) => sync.replay(event, options)) } -export function replayAll(events: SerializedEvent[], options?: { publish: boolean }) { +export function replayAll(events: SerializedEvent[], options?: { publish: boolean; ownerID?: string }) { return runtime.runSync((sync) => sync.replayAll(events, options)) } @@ -348,6 +354,16 @@ export function remove(aggregateID: string) { return runtime.runSync((sync) => sync.remove(aggregateID)) } +export function claim(aggregateID: string, ownerID: string) { + Database.use((db) => + db + .update(EventSequenceTable) + .set({ owner_id: ownerID }) + .where(eq(EventSequenceTable.aggregate_id, aggregateID)) + .run(), + ) +} + export function payloads() { return registry .entries() diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 10a05e3b1e..84f5670064 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -6,7 +6,7 @@ import { setTimeout as delay } from "node:timers/promises" import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer } from "effect" import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { asc, eq } from "drizzle-orm" +import { eq } from "drizzle-orm" import * as Log from "@opencode-ai/core/util/log" import { Flag } from "@opencode-ai/core/flag/flag" import { GlobalBus, type GlobalEvent } from "@/bus/global" @@ -16,11 +16,10 @@ import { ProjectTable } from "@/project/project.sql" import { Instance } from "@/project/instance" import { WithInstance } from "../../src/project/with-instance" import { Session as SessionNs } from "@/session/session" -import { SessionID, MessageID, PartID } from "@/session/schema" +import { SessionID } from "@/session/schema" import { SessionTable } from "@/session/session.sql" -import { ModelID, ProviderID } from "@/provider/schema" import { SyncEvent } from "@/sync" -import { EventSequenceTable, EventTable } from "@/sync/event.sql" +import { EventSequenceTable } from "@/sync/event.sql" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideTmpdirInstance, tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -111,8 +110,8 @@ async function withInstance(fn: (dir: string) => T | Promise) { const runWorkspace = (effect: Effect.Effect) => AppRuntime.runPromise(effect) const createWorkspace = (input: WorkspaceOld.CreateInput) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.create(input))) -const restoreWorkspaceSession = (input: WorkspaceOld.SessionRestoreInput) => - runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionRestore(input))) +const warpWorkspaceSession = (input: WorkspaceOld.SessionWarpInput) => + runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.sessionWarp(input))) const listWorkspaces = (project: Parameters[0]) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.list(project))) const getWorkspace = (id: WorkspaceID) => runWorkspace(WorkspaceOld.Service.use((workspace) => workspace.get(id))) @@ -317,48 +316,24 @@ function sessionSequence(sessionID: SessionID) { )?.seq } -function eventRows(sessionID: SessionID) { +function sessionSequenceOwner(sessionID: SessionID) { return Database.use((db) => db - .select({ seq: EventTable.seq, type: EventTable.type, data: EventTable.data }) - .from(EventTable) - .where(eq(EventTable.aggregate_id, sessionID)) - .orderBy(asc(EventTable.seq)) - .all(), - ) + .select({ ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .where(eq(EventSequenceTable.aggregate_id, sessionID)) + .get(), + )?.ownerID } function sessionUpdatedType() { return SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version) } -function replaceSessionEvents(sessionID: SessionID, count: number) { - Database.use((db) => { - db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, sessionID)).run() - if (count === 0) return - - db.insert(EventSequenceTable) - .values({ aggregate_id: sessionID, seq: count - 1 }) - .run() - db.insert(EventTable) - .values( - Array.from({ length: count }, (_, i) => ({ - id: `evt_${unique(`manual-${i}`)}`, - aggregate_id: sessionID, - seq: i, - type: sessionUpdatedType(), - data: { sessionID, info: { title: `manual ${i}` } }, - })), - ) - .run() - }) -} - describe("workspace-old schemas and exports", () => { test("keeps the historical event type names", () => { expect(WorkspaceOld.Event.Ready.type).toBe("workspace.ready") expect(WorkspaceOld.Event.Failed.type).toBe("workspace.failed") - expect(WorkspaceOld.Event.Restore.type).toBe("workspace.restore") expect(WorkspaceOld.Event.Status.type).toBe("workspace.status") }) @@ -375,17 +350,6 @@ describe("workspace-old schemas and exports", () => { expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, id: "bad" })).toThrow() expect(() => WorkspaceOld.CreateInput.zod.parse({ ...input, branch: 1 })).toThrow() }) - - test("validates session restore input", () => { - const input = { - workspaceID: WorkspaceID.ascending("wrk_schema_restore"), - sessionID: SessionID.descending("ses_schema_restore"), - } - - expect(WorkspaceOld.SessionRestoreInput.zod.parse(input)).toEqual(input) - expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, workspaceID: "bad" })).toThrow() - expect(() => WorkspaceOld.SessionRestoreInput.zod.parse({ ...input, sessionID: "bad" })).toThrow() - }) }) describe("workspace-old CRUD", () => { @@ -651,6 +615,144 @@ describe("workspace-old CRUD", () => { expect(await getWorkspace(info.id)).toBeUndefined() }) }) + + test("sessionWarp moves a session into a local workspace and claims ownership", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-prev-local") + const targetType = unique("warp-target-local") + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter) + registerAdapter(Instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: target.id, sessionID: session.id }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBe(target.id) + expect(sessionSequenceOwner(session.id)).toBe(target.id) + }) + }) + + test("sessionWarp detaches a session to the local project and claims project ownership", async () => { + await withInstance(async (dir) => { + const previousType = unique("warp-detach-local") + const previous = workspaceInfo(Instance.project.id, previousType) + insertWorkspace(previous) + registerAdapter(Instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter) + const session = await AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create({}))) + attachSessionToWorkspace(session.id, previous.id) + + await warpWorkspaceSession({ workspaceID: null, sessionID: session.id }) + + expect( + Database.use((db) => + db + .select({ workspaceID: SessionTable.workspace_id }) + .from(SessionTable) + .where(eq(SessionTable.id, session.id)) + .get(), + )?.workspaceID, + ).toBeNull() + expect(sessionSequenceOwner(session.id)).toBe(Instance.project.id) + }) + }) + + it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { + const calls: FetchCall[] = [] + let historySessionID: SessionID | undefined + let historyNextSeq = 0 + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + const call = { + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + } + calls.push(call) + if (call.url.pathname === "/warp-source/sync/history") { + return yield* HttpServerResponse.json([ + { + id: `evt_${unique("warp-source-history")}`, + aggregate_id: historySessionID!, + seq: historyNextSeq, + type: sessionUpdatedType(), + data: { sessionID: historySessionID!, info: { title: "from source history" } }, + }, + ]) + } + if (call.url.pathname === "/warp-target/sync/replay") + return yield* HttpServerResponse.json({ sessionID: "ok" }) + if (call.url.pathname === "/warp-target/sync/steal") + return yield* HttpServerResponse.json({ sessionID: "ok" }) + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + () => + Effect.gen(function* () { + const workspace = yield* WorkspaceOld.Service + const sessionSvc = yield* SessionNs.Service + const previousType = unique("warp-remote-source") + const targetType = unique("warp-remote-target") + const previous = workspaceInfo(Instance.project.id, previousType) + const target = workspaceInfo(Instance.project.id, targetType, { directory: "remote-target-dir" }) + insertWorkspace(previous) + insertWorkspace(target) + registerAdapter(Instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter) + registerAdapter(Instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter) + const session = yield* sessionSvc.create({}) + attachSessionToWorkspace(session.id, previous.id) + historySessionID = session.id + historyNextSeq = (sessionSequence(session.id) ?? -1) + 1 + + yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id }) + + expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([ + "POST /warp-source/sync/history", + "POST /warp-target/sync/replay", + "POST /warp-target/sync/steal", + ]) + expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 }) + expect(calls[1].json).toMatchObject({ + directory: "remote-target-dir", + events: [ + { + aggregateID: session.id, + seq: 0, + type: SyncEvent.versionedType(SessionNs.Event.Created.type, SessionNs.Event.Created.version), + }, + { + aggregateID: session.id, + seq: historyNextSeq, + type: sessionUpdatedType(), + }, + ], + }) + expect(calls[2].json).toEqual({ sessionID: session.id }) + expect((yield* sessionSvc.get(session.id)).title).toBe("from source history") + expect(sessionSequenceOwner(session.id)).toBe(target.id) + }), + { git: true }, + ) + }) + }) }) describe("workspace-old sync state", () => { @@ -1215,313 +1317,3 @@ describe("workspace-old waitForSync", () => { }) }, 7000) }) - -describe("workspace-old sessionRestore", () => { - test("throws when the workspace is missing", async () => { - await withInstance(async () => { - await expect( - restoreWorkspaceSession({ - workspaceID: WorkspaceID.ascending("wrk_restore_missing"), - sessionID: SessionID.descending("ses_restore_missing_workspace"), - }), - ).rejects.toThrow("Workspace not found: wrk_restore_missing") - }) - }) - - test("throws when switching a missing session fails", async () => { - await withInstance(async (dir) => { - const type = unique("restore-missing-session") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) - - await expect( - restoreWorkspaceSession({ workspaceID: info.id, sessionID: SessionID.descending("ses_missing_restore") }), - ).rejects.toThrow("NotFoundError") - await removeWorkspace(info.id) - }) - }) - - it.live("posts remote replay batches of 10, emits progress, and includes the workspace update event", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - const call = { - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - } - if (call.url.pathname === "/restore/sync/replay") { - replay.push(call) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - } - return HttpServerResponse.text("unexpected", { status: 500 }) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-remote") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter( - Instance.project.id, - type, - remoteAdapter(`${url}/restore/?ignored=1#hash`, { - directory: dir, - headers: { authorization: "Bearer restore" }, - }).adapter, - ) - const session = yield* sessionSvc.create({ title: "restore remote" }) - replaceSessionEvents(session.id, 24) - - const result = yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }) - - expect(result).toEqual({ total: 3 }) - expect(replay).toHaveLength(3) - expect(replay.map((call) => call.url.pathname + call.url.search + call.url.hash)).toEqual([ - "/restore/sync/replay", - "/restore/sync/replay", - "/restore/sync/replay", - ]) - expect(replay.every((call) => call.headers.get("authorization") === "Bearer restore")).toBe(true) - expect(replay.every((call) => call.headers.get("content-type") === "application/json")).toBe(true) - expect(replay.map((call) => (call.json as { events: unknown[] }).events.length)).toEqual([10, 10, 5]) - expect(replay.map((call) => (call.json as { directory: string }).directory)).toEqual([dir, dir, dir]) - expect( - replay.flatMap((call) => - (call.json as { events: Array<{ seq: number }> }).events.map((event) => event.seq), - ), - ).toEqual(Array.from({ length: 25 }, (_, i) => i)) - expect( - (replay[2].json as { events: Array<{ seq: number; type: string; data: unknown }> }).events.at(-1), - ).toMatchObject({ - seq: 24, - type: sessionUpdatedType(), - data: { sessionID: session.id, info: { workspaceID: info.id } }, - }) - expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0, 1, 2, 3]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ) - }) - }) - - it.live("remote restore sends an empty directory string when the workspace directory is null", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - () => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const type = unique("restore-null-dir") - const info = workspaceInfo(Instance.project.id, type, { directory: null }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/null-dir`, { directory: null }).adapter) - const session = yield* sessionSvc.create({ title: "null dir" }) - replaceSessionEvents(session.id, 0) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 1, - }) - expect((replay[0].json as { directory: string }).directory).toBe("") - expect((replay[0].json as { events: unknown[] }).events).toHaveLength(1) - yield* workspace.remove(info.id) - }), - { git: true }, - ) - }) - }) - - it.live("remote restore failures include status and body and do not emit completed batch progress", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.text("replay failed", { status: 503 }) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-remote-fail") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/fail`, { directory: dir }).adapter) - const session = yield* sessionSvc.create({ title: "restore fail" }) - replaceSessionEvents(session.id, 11) - - const error = yield* Effect.flip( - workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id }), - ) - expect((error as Error).message).toContain( - `Failed to replay session ${session.id} into workspace ${info.id}: HTTP 503 replay failed`, - ) - - expect(replay).toHaveLength(1) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ) - }) - }) - - it.live("local restore replays batches and emits progress", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const captured = captureGlobalEvents() - try { - const type = unique("restore-local") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, localAdapter(dir).adapter) - const session = yield* sessionSvc.create({ title: "restore local" }) - replaceSessionEvents(session.id, 20) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 3, - }) - expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(info.id) - expect(eventRows(session.id).map((row) => row.seq)).toEqual(Array.from({ length: 21 }, (_, i) => i)) - expect( - captured.events - .filter( - (event) => event.workspace === info.id && event.payload.type === WorkspaceOld.Event.Restore.type, - ) - .map((event) => event.payload.properties.step), - ).toEqual([0, 1, 2, 3]) - yield* workspace.remove(info.id) - } finally { - captured.dispose() - } - }), - { git: true }, - ), - ) - - it.live("session restore includes real message and part events in sequence order", () => { - const replay: FetchCall[] = [] - return Effect.gen(function* () { - yield* HttpServer.serveEffect()( - Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const bodyText = yield* req.text - replay.push({ - url: new URL(req.url, "http://localhost"), - method: req.method, - headers: new Headers(req.headers), - bodyText, - json: bodyText ? JSON.parse(bodyText) : undefined, - }) - return HttpServerResponse.fromWeb(Response.json({ ok: true })) - }), - ) - const url = yield* serverUrl() - yield* provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const workspace = yield* WorkspaceOld.Service - const sessionSvc = yield* SessionNs.Service - const type = unique("restore-real-events") - const info = workspaceInfo(Instance.project.id, type, { directory: dir }) - insertWorkspace(info) - registerAdapter(Instance.project.id, type, remoteAdapter(`${url}/real`, { directory: dir }).adapter) - const session = yield* sessionSvc.create({ title: "real events" }) - for (let i = 0; i < 3; i++) { - const msg = yield* sessionSvc.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: session.id, - agent: "build", - model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") }, - time: { created: Date.now() }, - }) - yield* sessionSvc.updatePart({ - id: PartID.ascending(), - sessionID: session.id, - messageID: msg.id, - type: "text", - text: `message ${i}`, - }) - } - const before = eventRows(session.id) - - expect(yield* workspace.sessionRestore({ workspaceID: info.id, sessionID: session.id })).toEqual({ - total: 1, - }) - - const posted = (replay[0].json as { events: Array<{ seq: number; type: string }> }).events - expect(posted.map((event) => event.seq)).toEqual([...before.map((row) => row.seq), before.at(-1)!.seq + 1]) - expect(posted.map((event) => event.type).slice(0, -1)).toEqual(before.map((row) => row.type)) - expect(posted.at(-1)?.type).toBe(sessionUpdatedType()) - yield* workspace.remove(info.id) - }), - { git: true }, - ) - }) - }) -}) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 193c2971a1..21bf4120c9 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -168,22 +168,19 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-test", branch: null, extra: null }), + body: JSON.stringify({ type: "local-test", branch: null }), }) expect(created.status).toBe(200) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir)) - const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, { + const warped = yield* request(WorkspacePaths.warp, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ sessionID: session.id }), - }) - expect(restored.status).toBe(200) - expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({ - total: expect.any(Number), + body: JSON.stringify({ id: workspace.id, sessionID: session.id }), }) + expect(warped.status).toBe(204) const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) expect(removed.status).toBe(200) @@ -212,7 +209,6 @@ describe("workspace HttpApi", () => { expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ type: "local-test", name: "local-test", - extra: null, }) }), ) @@ -257,7 +253,6 @@ describe("workspace HttpApi", () => { expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({ type: "local-test", name: "local-test", - extra: null, }) }), ) @@ -272,7 +267,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "local-target", branch: null, extra: null }), + body: JSON.stringify({ type: "local-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info @@ -327,7 +322,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "remote-target", branch: null, extra: null }), + body: JSON.stringify({ type: "remote-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info @@ -394,7 +389,7 @@ describe("workspace HttpApi", () => { const created = yield* request(WorkspacePaths.list, dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }), + body: JSON.stringify({ type: "remote-session-target", branch: null }), }) const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info const session = yield* Session.Service.use((svc) => svc.create()).pipe( diff --git a/packages/opencode/test/sync/index.test.ts b/packages/opencode/test/sync/index.test.ts index 234c5246ee..0986b39044 100644 --- a/packages/opencode/test/sync/index.test.ts +++ b/packages/opencode/test/sync/index.test.ts @@ -5,7 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Bus } from "../../src/bus" import { SyncEvent } from "../../src/sync" import { Database } from "@/storage/db" -import { EventTable } from "../../src/sync/event.sql" +import { EventSequenceTable, EventTable } from "../../src/sync/event.sql" import { MessageID } from "../../src/session/schema" import { Flag } from "@opencode-ai/core/flag/flag" import { initProjectors } from "../../src/server/projectors" @@ -252,5 +252,76 @@ describe("SyncEvent", () => { }), ), ) + + it.live( + "claims unowned event sequence on replay with ownerID", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.replay( + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "owned" }, + }, + { publish: false, ownerID: "owner-1" }, + ) + + const row = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .get(), + ) + expect(row).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ), + ) + + it.live( + "ignores replay from a different owner after sequence is claimed", + provideTmpdirInstance(() => + Effect.gen(function* () { + const { Created } = setup() + const id = MessageID.ascending() + + yield* SyncEvent.use.replay( + { + id: "evt_1", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 0, + aggregateID: id, + data: { id, name: "first" }, + }, + { publish: false, ownerID: "owner-1" }, + ) + yield* SyncEvent.use.replay( + { + id: "evt_2", + type: SyncEvent.versionedType(Created.type, Created.version), + seq: 1, + aggregateID: id, + data: { id, name: "ignored" }, + }, + { publish: false, ownerID: "owner-2" }, + ) + + const events = Database.use((db) => db.select().from(EventTable).all()) + const sequence = Database.use((db) => + db + .select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id }) + .from(EventSequenceTable) + .get(), + ) + expect(events).toHaveLength(1) + expect(events[0].id).toBe("evt_1") + expect(sequence).toEqual({ seq: 0, ownerID: "owner-1" }) + }), + ), + ) }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index e94132c2b2..ab191b0566 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4,58 +4,84 @@ import { client } from "./client.gen.js" import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js" import type { AgentPartInput, + AppAgentsErrors, AppAgentsResponses, AppLogErrors, AppLogResponses, + AppSkillsErrors, AppSkillsResponses, Auth as Auth3, AuthRemoveErrors, AuthRemoveResponses, AuthSetErrors, AuthSetResponses, + CommandListErrors, CommandListResponses, Config as Config3, + ConfigGetErrors, ConfigGetResponses, + ConfigProvidersErrors, ConfigProvidersResponses, ConfigUpdateErrors, ConfigUpdateResponses, + EventSubscribeErrors, EventSubscribeResponses, EventTuiCommandExecute2, EventTuiPromptAppend2, EventTuiSessionSelect2, EventTuiToastShow2, + ExperimentalConsoleGetErrors, ExperimentalConsoleGetResponses, + ExperimentalConsoleListOrgsErrors, ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, + ExperimentalResourceListErrors, ExperimentalResourceListResponses, + ExperimentalSessionListErrors, ExperimentalSessionListResponses, + ExperimentalWorkspaceAdapterListErrors, ExperimentalWorkspaceAdapterListResponses, ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateResponses, + ExperimentalWorkspaceListErrors, ExperimentalWorkspaceListResponses, ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceSessionRestoreErrors, - ExperimentalWorkspaceSessionRestoreResponses, + ExperimentalWorkspaceStatusErrors, ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceWarpErrors, + ExperimentalWorkspaceWarpResponses, + FileListErrors, FileListResponses, FilePartInput, FilePartSource, + FileReadErrors, FileReadResponses, + FileStatusErrors, FileStatusResponses, + FindFilesErrors, FindFilesResponses, + FindSymbolsErrors, FindSymbolsResponses, + FindTextErrors, FindTextResponses, + FormatterStatusErrors, FormatterStatusResponses, + GlobalConfigGetErrors, GlobalConfigGetResponses, GlobalConfigUpdateErrors, GlobalConfigUpdateResponses, + GlobalDisposeErrors, GlobalDisposeResponses, + GlobalEventErrors, GlobalEventResponses, + GlobalHealthErrors, GlobalHealthResponses, GlobalUpgradeErrors, GlobalUpgradeResponses, + InstanceDisposeErrors, InstanceDisposeResponses, + LspStatusErrors, LspStatusResponses, McpAddErrors, McpAddResponses, @@ -67,10 +93,13 @@ import type { McpAuthRemoveResponses, McpAuthStartErrors, McpAuthStartResponses, + McpConnectErrors, McpConnectResponses, + McpDisconnectErrors, McpDisconnectResponses, McpLocalConfig, McpRemoteConfig, + McpStatusErrors, McpStatusResponses, OutputFormat, Part as Part2, @@ -78,20 +107,27 @@ import type { PartDeleteResponses, PartUpdateErrors, PartUpdateResponses, + PathGetErrors, PathGetResponses, + PermissionListErrors, PermissionListResponses, PermissionReplyErrors, PermissionReplyResponses, PermissionRespondErrors, PermissionRespondResponses, PermissionRuleset, + ProjectCurrentErrors, ProjectCurrentResponses, + ProjectInitGitErrors, ProjectInitGitResponses, + ProjectListErrors, ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, Prompt, + ProviderAuthErrors, ProviderAuthResponses, + ProviderListErrors, ProviderListResponses, ProviderOauthAuthorizeErrors, ProviderOauthAuthorizeResponses, @@ -105,13 +141,16 @@ import type { PtyCreateResponses, PtyGetErrors, PtyGetResponses, + PtyListErrors, PtyListResponses, PtyRemoveErrors, PtyRemoveResponses, + PtyShellsErrors, PtyShellsResponses, PtyUpdateErrors, PtyUpdateResponses, QuestionAnswer, + QuestionListErrors, QuestionListResponses, QuestionRejectErrors, QuestionRejectResponses, @@ -130,12 +169,15 @@ import type { SessionDeleteMessageResponses, SessionDeleteResponses, SessionDelivery, + SessionDiffErrors, SessionDiffResponses, + SessionForkErrors, SessionForkResponses, SessionGetErrors, SessionGetResponses, SessionInitErrors, SessionInitResponses, + SessionListErrors, SessionListResponses, SessionMessageErrors, SessionMessageResponses, @@ -168,7 +210,10 @@ import type { SyncHistoryListResponses, SyncReplayErrors, SyncReplayResponses, + SyncStartErrors, SyncStartResponses, + SyncStealErrors, + SyncStealResponses, TextPartInput, ToolIdsErrors, ToolIdsResponses, @@ -176,34 +221,50 @@ import type { ToolListResponses, TuiAppendPromptErrors, TuiAppendPromptResponses, + TuiClearPromptErrors, TuiClearPromptResponses, + TuiControlNextErrors, TuiControlNextResponses, + TuiControlResponseErrors, TuiControlResponseResponses, TuiExecuteCommandErrors, TuiExecuteCommandResponses, + TuiOpenHelpErrors, TuiOpenHelpResponses, + TuiOpenModelsErrors, TuiOpenModelsResponses, + TuiOpenSessionsErrors, TuiOpenSessionsResponses, + TuiOpenThemesErrors, TuiOpenThemesResponses, TuiPublishErrors, TuiPublishResponses, TuiSelectSessionErrors, TuiSelectSessionResponses, + TuiShowToastErrors, TuiShowToastResponses, + TuiSubmitPromptErrors, TuiSubmitPromptResponses, + V2SessionCompactErrors, V2SessionCompactResponses, + V2SessionContextErrors, V2SessionContextResponses, V2SessionListErrors, V2SessionListResponses, V2SessionMessagesErrors, V2SessionMessagesResponses, + V2SessionPromptErrors, V2SessionPromptResponses, + V2SessionWaitErrors, V2SessionWaitResponses, + VcsDiffErrors, VcsDiffResponses, + VcsGetErrors, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, + WorktreeListErrors, WorktreeListResponses, WorktreeRemoveErrors, WorktreeRemoveInput, @@ -381,7 +442,7 @@ export class App extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/agent", ...options, ...params, @@ -411,7 +472,7 @@ export class App extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/skill", ...options, ...params, @@ -426,7 +487,7 @@ export class Config extends HeyApiClient { * Retrieve the current global OpenCode configuration settings and preferences. */ public get(options?: Options) { - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/global/config", ...options, }) @@ -464,7 +525,7 @@ export class Global extends HeyApiClient { * Get health information about the OpenCode server. */ public health(options?: Options) { - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/global/health", ...options, }) @@ -476,7 +537,7 @@ export class Global extends HeyApiClient { * Subscribe to global events from the OpenCode system using server-sent events. */ public event(options?: Options) { - return (options?.client ?? this.client).sse.get({ + return (options?.client ?? this.client).sse.get({ url: "/global/event", ...options, }) @@ -488,7 +549,7 @@ export class Global extends HeyApiClient { * Clean up and dispose all OpenCode instances, releasing all resources. */ public dispose(options?: Options) { - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/global/dispose", ...options, }) @@ -548,7 +609,7 @@ export class Event extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).sse.get({ + return (options?.client ?? this.client).sse.get({ url: "/event", ...options, ...params, @@ -580,7 +641,7 @@ export class Config2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/config", ...options, ...params, @@ -647,7 +708,7 @@ export class Config2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/config/providers", ...options, ...params, @@ -679,7 +740,11 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalConsoleGetResponses, + ExperimentalConsoleGetErrors, + ThrowOnError + >({ url: "/experimental/console", ...options, ...params, @@ -709,7 +774,11 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalConsoleListOrgsResponses, + ExperimentalConsoleListOrgsErrors, + ThrowOnError + >({ url: "/experimental/console/orgs", ...options, ...params, @@ -792,7 +861,11 @@ export class Session extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalSessionListResponses, + ExperimentalSessionListErrors, + ThrowOnError + >({ url: "/experimental/session", ...options, ...params, @@ -824,7 +897,11 @@ export class Resource extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalResourceListResponses, + ExperimentalResourceListErrors, + ThrowOnError + >({ url: "/experimental/resource", ...options, ...params, @@ -856,7 +933,11 @@ export class Adapter extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalWorkspaceAdapterListResponses, + ExperimentalWorkspaceAdapterListErrors, + ThrowOnError + >({ url: "/experimental/workspace/adapter", ...options, ...params, @@ -888,7 +969,11 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalWorkspaceListResponses, + ExperimentalWorkspaceListErrors, + ThrowOnError + >({ url: "/experimental/workspace", ...options, ...params, @@ -965,7 +1050,11 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get< + ExperimentalWorkspaceStatusResponses, + ExperimentalWorkspaceStatusErrors, + ThrowOnError + >({ url: "/experimental/workspace/status", ...options, ...params, @@ -1009,15 +1098,15 @@ export class Workspace extends HeyApiClient { } /** - * Restore session into workspace + * Warp session into workspace * - * Replay a session's sync events into the target workspace in batches. + * Move a session's sync history into the target workspace, or detach it to the local project. */ - public sessionRestore( - parameters: { - id: string + public warp( + parameters?: { directory?: string workspace?: string + id?: string | null sessionID?: string }, options?: Options, @@ -1027,20 +1116,20 @@ export class Workspace extends HeyApiClient { [ { args: [ - { in: "path", key: "id" }, { in: "query", key: "directory" }, { in: "query", key: "workspace" }, + { in: "body", key: "id" }, { in: "body", key: "sessionID" }, ], }, ], ) return (options?.client ?? this.client).post< - ExperimentalWorkspaceSessionRestoreResponses, - ExperimentalWorkspaceSessionRestoreErrors, + ExperimentalWorkspaceWarpResponses, + ExperimentalWorkspaceWarpErrors, ThrowOnError >({ - url: "/experimental/workspace/{id}/session-restore", + url: "/experimental/workspace/warp", ...options, ...params, headers: { @@ -1206,7 +1295,7 @@ export class Worktree extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/experimental/worktree", ...options, ...params, @@ -1314,7 +1403,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find", ...options, ...params, @@ -1352,7 +1441,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find/file", ...options, ...params, @@ -1384,7 +1473,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find/symbol", ...options, ...params, @@ -1418,7 +1507,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file", ...options, ...params, @@ -1450,7 +1539,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file/content", ...options, ...params, @@ -1480,7 +1569,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file/status", ...options, ...params, @@ -1512,7 +1601,7 @@ export class Instance extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/instance/dispose", ...options, ...params, @@ -1544,7 +1633,7 @@ export class Path extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/path", ...options, ...params, @@ -1576,7 +1665,7 @@ export class Vcs extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/vcs", ...options, ...params, @@ -1608,7 +1697,7 @@ export class Vcs extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/vcs/diff", ...options, ...params, @@ -1640,7 +1729,7 @@ export class Command extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/command", ...options, ...params, @@ -1672,7 +1761,7 @@ export class Lsp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/lsp", ...options, ...params, @@ -1704,7 +1793,7 @@ export class Formatter extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/formatter", ...options, ...params, @@ -1875,7 +1964,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/mcp", ...options, ...params, @@ -1944,7 +2033,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/mcp/{name}/connect", ...options, ...params, @@ -1974,7 +2063,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/mcp/{name}/disconnect", ...options, ...params, @@ -2011,7 +2100,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/project", ...options, ...params, @@ -2041,7 +2130,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/project/current", ...options, ...params, @@ -2071,7 +2160,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/project/git/init", ...options, ...params, @@ -2155,7 +2244,7 @@ export class Pty extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/pty/shells", ...options, ...params, @@ -2185,7 +2274,7 @@ export class Pty extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/pty", ...options, ...params, @@ -2436,7 +2525,7 @@ export class Question extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/question", ...options, ...params, @@ -2539,7 +2628,7 @@ export class Permission extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/permission", ...options, ...params, @@ -2749,7 +2838,7 @@ export class Provider extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/provider", ...options, ...params, @@ -2779,7 +2868,7 @@ export class Provider extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/provider/auth", ...options, ...params, @@ -2828,7 +2917,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/session", ...options, ...params, @@ -3116,7 +3205,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/session/{sessionID}/diff", ...options, ...params, @@ -3318,7 +3407,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/session/{sessionID}/fork", ...options, ...params, @@ -3894,7 +3983,7 @@ export class Sync extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/sync/start", ...options, ...params, @@ -3956,6 +4045,43 @@ export class Sync extends HeyApiClient { }) } + /** + * Steal session into workspace + * + * Update a session to belong to the current workspace through the sync event system. + */ + public steal( + parameters?: { + directory?: string + workspace?: string + sessionID?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "sessionID" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/sync/steal", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + private _history?: History get history(): History { return (this._history ??= new History({ client: this.client })) @@ -4022,7 +4148,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/prompt", ...options, ...params, @@ -4059,7 +4185,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/compact", ...options, ...params, @@ -4091,7 +4217,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/wait", ...options, ...params, @@ -4123,7 +4249,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/api/session/{sessionID}/context", ...options, ...params, @@ -4194,7 +4320,7 @@ export class Control extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/tui/control/next", ...options, ...params, @@ -4226,7 +4352,7 @@ export class Control extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/control/response", ...options, ...params, @@ -4300,7 +4426,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-help", ...options, ...params, @@ -4330,7 +4456,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-sessions", ...options, ...params, @@ -4360,7 +4486,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-themes", ...options, ...params, @@ -4390,7 +4516,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-models", ...options, ...params, @@ -4420,7 +4546,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/submit-prompt", ...options, ...params, @@ -4450,7 +4576,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/clear-prompt", ...options, ...params, @@ -4525,7 +4651,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/show-toast", ...options, ...params, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 86c5a762b1..a40b567f8c 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -35,7 +35,6 @@ export type Event = | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed - | EventWorkspaceRestore | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed @@ -801,7 +800,6 @@ export type GlobalEvent = { | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed - | EventWorkspaceRestore | EventWorkspaceStatus | EventWorktreeReady | EventWorktreeFailed @@ -2478,17 +2476,6 @@ export type EventWorkspaceFailed = { } } -export type EventWorkspaceRestore = { - id: string - type: "workspace.restore" - properties: { - workspaceID: string - sessionID: string - total: number - step: number - } -} - export type EventWorkspaceStatus = { id: string type: "workspace.status" @@ -3358,6 +3345,15 @@ export type GlobalHealthData = { url: "/global/health" } +export type GlobalHealthErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalHealthError = GlobalHealthErrors[keyof GlobalHealthErrors] + export type GlobalHealthResponses = { /** * Health information @@ -3377,6 +3373,15 @@ export type GlobalEventData = { url: "/global/event" } +export type GlobalEventErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalEventError = GlobalEventErrors[keyof GlobalEventErrors] + export type GlobalEventResponses = { /** * Event stream @@ -3393,6 +3398,15 @@ export type GlobalConfigGetData = { url: "/global/config" } +export type GlobalConfigGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalConfigGetError = GlobalConfigGetErrors[keyof GlobalConfigGetErrors] + export type GlobalConfigGetResponses = { /** * Get global config info @@ -3434,6 +3448,15 @@ export type GlobalDisposeData = { url: "/global/dispose" } +export type GlobalDisposeErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type GlobalDisposeError = GlobalDisposeErrors[keyof GlobalDisposeErrors] + export type GlobalDisposeResponses = { /** * Global disposed @@ -3488,6 +3511,15 @@ export type EventSubscribeData = { url: "/event" } +export type EventSubscribeErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type EventSubscribeError = EventSubscribeErrors[keyof EventSubscribeErrors] + export type EventSubscribeResponses = { /** * Event stream @@ -3507,6 +3539,15 @@ export type ConfigGetData = { url: "/config" } +export type ConfigGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ConfigGetError = ConfigGetErrors[keyof ConfigGetErrors] + export type ConfigGetResponses = { /** * Get config info @@ -3554,6 +3595,15 @@ export type ConfigProvidersData = { url: "/config/providers" } +export type ConfigProvidersErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ConfigProvidersError = ConfigProvidersErrors[keyof ConfigProvidersErrors] + export type ConfigProvidersResponses = { /** * List of providers @@ -3578,6 +3628,15 @@ export type ExperimentalConsoleGetData = { url: "/experimental/console" } +export type ExperimentalConsoleGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalConsoleGetError = ExperimentalConsoleGetErrors[keyof ExperimentalConsoleGetErrors] + export type ExperimentalConsoleGetResponses = { /** * Active Console provider metadata @@ -3597,6 +3656,16 @@ export type ExperimentalConsoleListOrgsData = { url: "/experimental/console/orgs" } +export type ExperimentalConsoleListOrgsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalConsoleListOrgsError = + ExperimentalConsoleListOrgsErrors[keyof ExperimentalConsoleListOrgsErrors] + export type ExperimentalConsoleListOrgsResponses = { /** * Switchable Console orgs @@ -3735,6 +3804,15 @@ export type WorktreeListData = { url: "/experimental/worktree" } +export type WorktreeListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type WorktreeListError = WorktreeListErrors[keyof WorktreeListErrors] + export type WorktreeListResponses = { /** * List of worktree directories @@ -3816,6 +3894,15 @@ export type ExperimentalSessionListData = { url: "/experimental/session" } +export type ExperimentalSessionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalSessionListError = ExperimentalSessionListErrors[keyof ExperimentalSessionListErrors] + export type ExperimentalSessionListResponses = { /** * List of sessions @@ -3835,6 +3922,15 @@ export type ExperimentalResourceListData = { url: "/experimental/resource" } +export type ExperimentalResourceListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalResourceListError = ExperimentalResourceListErrors[keyof ExperimentalResourceListErrors] + export type ExperimentalResourceListResponses = { /** * MCP resources @@ -3858,6 +3954,15 @@ export type FindTextData = { url: "/find" } +export type FindTextErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FindTextError = FindTextErrors[keyof FindTextErrors] + export type FindTextResponses = { /** * Matches @@ -3897,6 +4002,15 @@ export type FindFilesData = { url: "/find/file" } +export type FindFilesErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FindFilesError = FindFilesErrors[keyof FindFilesErrors] + export type FindFilesResponses = { /** * File paths @@ -3917,6 +4031,15 @@ export type FindSymbolsData = { url: "/find/symbol" } +export type FindSymbolsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FindSymbolsError = FindSymbolsErrors[keyof FindSymbolsErrors] + export type FindSymbolsResponses = { /** * Symbols @@ -3937,6 +4060,15 @@ export type FileListData = { url: "/file" } +export type FileListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FileListError = FileListErrors[keyof FileListErrors] + export type FileListResponses = { /** * Files and directories @@ -3957,6 +4089,15 @@ export type FileReadData = { url: "/file/content" } +export type FileReadErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FileReadError = FileReadErrors[keyof FileReadErrors] + export type FileReadResponses = { /** * File content @@ -3976,6 +4117,15 @@ export type FileStatusData = { url: "/file/status" } +export type FileStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FileStatusError = FileStatusErrors[keyof FileStatusErrors] + export type FileStatusResponses = { /** * File status @@ -3995,6 +4145,15 @@ export type InstanceDisposeData = { url: "/instance/dispose" } +export type InstanceDisposeErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type InstanceDisposeError = InstanceDisposeErrors[keyof InstanceDisposeErrors] + export type InstanceDisposeResponses = { /** * Instance disposed @@ -4014,6 +4173,15 @@ export type PathGetData = { url: "/path" } +export type PathGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PathGetError = PathGetErrors[keyof PathGetErrors] + export type PathGetResponses = { /** * Path @@ -4033,6 +4201,15 @@ export type VcsGetData = { url: "/vcs" } +export type VcsGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsGetError = VcsGetErrors[keyof VcsGetErrors] + export type VcsGetResponses = { /** * VCS info @@ -4053,6 +4230,15 @@ export type VcsDiffData = { url: "/vcs/diff" } +export type VcsDiffErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type VcsDiffError = VcsDiffErrors[keyof VcsDiffErrors] + export type VcsDiffResponses = { /** * VCS diff @@ -4072,6 +4258,15 @@ export type CommandListData = { url: "/command" } +export type CommandListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type CommandListError = CommandListErrors[keyof CommandListErrors] + export type CommandListResponses = { /** * List of commands @@ -4091,6 +4286,15 @@ export type AppAgentsData = { url: "/agent" } +export type AppAgentsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppAgentsError = AppAgentsErrors[keyof AppAgentsErrors] + export type AppAgentsResponses = { /** * List of agents @@ -4110,6 +4314,15 @@ export type AppSkillsData = { url: "/skill" } +export type AppSkillsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AppSkillsError = AppSkillsErrors[keyof AppSkillsErrors] + export type AppSkillsResponses = { /** * List of skills @@ -4134,6 +4347,15 @@ export type LspStatusData = { url: "/lsp" } +export type LspStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type LspStatusError = LspStatusErrors[keyof LspStatusErrors] + export type LspStatusResponses = { /** * LSP server status @@ -4153,6 +4375,15 @@ export type FormatterStatusData = { url: "/formatter" } +export type FormatterStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type FormatterStatusError = FormatterStatusErrors[keyof FormatterStatusErrors] + export type FormatterStatusResponses = { /** * Formatter status @@ -4172,6 +4403,15 @@ export type McpStatusData = { url: "/mcp" } +export type McpStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpStatusError = McpStatusErrors[keyof McpStatusErrors] + export type McpStatusResponses = { /** * MCP server status @@ -4229,6 +4469,10 @@ export type McpAuthRemoveData = { } export type McpAuthRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Not found */ @@ -4262,7 +4506,7 @@ export type McpAuthStartData = { export type McpAuthStartErrors = { /** - * McpUnsupportedOAuthError + * McpUnsupportedOAuthError | BadRequest */ 400: McpUnsupportedOAuthError /** @@ -4335,7 +4579,7 @@ export type McpAuthAuthenticateData = { export type McpAuthAuthenticateErrors = { /** - * McpUnsupportedOAuthError + * McpUnsupportedOAuthError | BadRequest */ 400: McpUnsupportedOAuthError /** @@ -4367,6 +4611,15 @@ export type McpConnectData = { url: "/mcp/{name}/connect" } +export type McpConnectErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpConnectError = McpConnectErrors[keyof McpConnectErrors] + export type McpConnectResponses = { /** * MCP server connected successfully @@ -4388,6 +4641,15 @@ export type McpDisconnectData = { url: "/mcp/{name}/disconnect" } +export type McpDisconnectErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type McpDisconnectError = McpDisconnectErrors[keyof McpDisconnectErrors] + export type McpDisconnectResponses = { /** * MCP server disconnected successfully @@ -4407,6 +4669,15 @@ export type ProjectListData = { url: "/project" } +export type ProjectListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProjectListError = ProjectListErrors[keyof ProjectListErrors] + export type ProjectListResponses = { /** * List of projects @@ -4426,6 +4697,15 @@ export type ProjectCurrentData = { url: "/project/current" } +export type ProjectCurrentErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProjectCurrentError = ProjectCurrentErrors[keyof ProjectCurrentErrors] + export type ProjectCurrentResponses = { /** * Current project information @@ -4445,6 +4725,15 @@ export type ProjectInitGitData = { url: "/project/git/init" } +export type ProjectInitGitErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProjectInitGitError = ProjectInitGitErrors[keyof ProjectInitGitErrors] + export type ProjectInitGitResponses = { /** * Project information after git initialization @@ -4511,6 +4800,15 @@ export type PtyShellsData = { url: "/pty/shells" } +export type PtyShellsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PtyShellsError = PtyShellsErrors[keyof PtyShellsErrors] + export type PtyShellsResponses = { /** * List of shells @@ -4534,6 +4832,15 @@ export type PtyListData = { url: "/pty" } +export type PtyListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PtyListError = PtyListErrors[keyof PtyListErrors] + export type PtyListResponses = { /** * List of sessions @@ -4592,6 +4899,10 @@ export type PtyRemoveData = { } export type PtyRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Not found */ @@ -4622,6 +4933,10 @@ export type PtyGetData = { } export type PtyGetErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Not found */ @@ -4688,6 +5003,10 @@ export type PtyConnectTokenData = { } export type PtyConnectTokenErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Forbidden */ @@ -4722,6 +5041,15 @@ export type QuestionListData = { url: "/question" } +export type QuestionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type QuestionListError = QuestionListErrors[keyof QuestionListErrors] + export type QuestionListResponses = { /** * List of pending questions @@ -4814,6 +5142,15 @@ export type PermissionListData = { url: "/permission" } +export type PermissionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type PermissionListError = PermissionListErrors[keyof PermissionListErrors] + export type PermissionListResponses = { /** * List of pending permissions @@ -4870,6 +5207,15 @@ export type ProviderListData = { url: "/provider" } +export type ProviderListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderListError = ProviderListErrors[keyof ProviderListErrors] + export type ProviderListResponses = { /** * List of providers @@ -4895,6 +5241,15 @@ export type ProviderAuthData = { url: "/provider/auth" } +export type ProviderAuthErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ProviderAuthError2 = ProviderAuthErrors[keyof ProviderAuthErrors] + export type ProviderAuthResponses = { /** * Provider auth methods @@ -4996,6 +5351,15 @@ export type SessionListData = { url: "/session" } +export type SessionListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionListError = SessionListErrors[keyof SessionListErrors] + export type SessionListResponses = { /** * List of sessions @@ -5263,6 +5627,15 @@ export type SessionDiffData = { url: "/session/{sessionID}/diff" } +export type SessionDiffErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionDiffError = SessionDiffErrors[keyof SessionDiffErrors] + export type SessionDiffResponses = { /** * Successfully retrieved diff @@ -5450,6 +5823,15 @@ export type SessionForkData = { url: "/session/{sessionID}/fork" } +export type SessionForkErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SessionForkError = SessionForkErrors[keyof SessionForkErrors] + export type SessionForkResponses = { /** * 200 @@ -5973,6 +6355,15 @@ export type SyncStartData = { url: "/sync/start" } +export type SyncStartErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SyncStartError = SyncStartErrors[keyof SyncStartErrors] + export type SyncStartResponses = { /** * Workspace sync started @@ -6023,6 +6414,38 @@ export type SyncReplayResponses = { export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses] +export type SyncStealData = { + body?: { + sessionID: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/sync/steal" +} + +export type SyncStealErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type SyncStealError = SyncStealErrors[keyof SyncStealErrors] + +export type SyncStealResponses = { + /** + * Session stolen into workspace + */ + 200: { + sessionID: string + } +} + +export type SyncStealResponse = SyncStealResponses[keyof SyncStealResponses] + export type SyncHistoryListData = { body?: { [key: string]: number @@ -6104,6 +6527,15 @@ export type V2SessionPromptData = { url: "/api/session/{sessionID}/prompt" } +export type V2SessionPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type V2SessionPromptError = V2SessionPromptErrors[keyof V2SessionPromptErrors] + export type V2SessionPromptResponses = { /** * Session.Message @@ -6125,6 +6557,15 @@ export type V2SessionCompactData = { url: "/api/session/{sessionID}/compact" } +export type V2SessionCompactErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type V2SessionCompactError = V2SessionCompactErrors[keyof V2SessionCompactErrors] + export type V2SessionCompactResponses = { /** * @@ -6146,6 +6587,15 @@ export type V2SessionWaitData = { url: "/api/session/{sessionID}/wait" } +export type V2SessionWaitErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type V2SessionWaitError = V2SessionWaitErrors[keyof V2SessionWaitErrors] + export type V2SessionWaitResponses = { /** * @@ -6167,6 +6617,15 @@ export type V2SessionContextData = { url: "/api/session/{sessionID}/context" } +export type V2SessionContextErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type V2SessionContextError = V2SessionContextErrors[keyof V2SessionContextErrors] + export type V2SessionContextResponses = { /** * Success @@ -6246,6 +6705,15 @@ export type TuiOpenHelpData = { url: "/tui/open-help" } +export type TuiOpenHelpErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenHelpError = TuiOpenHelpErrors[keyof TuiOpenHelpErrors] + export type TuiOpenHelpResponses = { /** * Help dialog opened successfully @@ -6265,6 +6733,15 @@ export type TuiOpenSessionsData = { url: "/tui/open-sessions" } +export type TuiOpenSessionsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenSessionsError = TuiOpenSessionsErrors[keyof TuiOpenSessionsErrors] + export type TuiOpenSessionsResponses = { /** * Session dialog opened successfully @@ -6284,6 +6761,15 @@ export type TuiOpenThemesData = { url: "/tui/open-themes" } +export type TuiOpenThemesErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenThemesError = TuiOpenThemesErrors[keyof TuiOpenThemesErrors] + export type TuiOpenThemesResponses = { /** * Theme dialog opened successfully @@ -6303,6 +6789,15 @@ export type TuiOpenModelsData = { url: "/tui/open-models" } +export type TuiOpenModelsErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiOpenModelsError = TuiOpenModelsErrors[keyof TuiOpenModelsErrors] + export type TuiOpenModelsResponses = { /** * Model dialog opened successfully @@ -6322,6 +6817,15 @@ export type TuiSubmitPromptData = { url: "/tui/submit-prompt" } +export type TuiSubmitPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiSubmitPromptError = TuiSubmitPromptErrors[keyof TuiSubmitPromptErrors] + export type TuiSubmitPromptResponses = { /** * Prompt submitted successfully @@ -6341,6 +6845,15 @@ export type TuiClearPromptData = { url: "/tui/clear-prompt" } +export type TuiClearPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiClearPromptError = TuiClearPromptErrors[keyof TuiClearPromptErrors] + export type TuiClearPromptResponses = { /** * Prompt cleared successfully @@ -6395,6 +6908,15 @@ export type TuiShowToastData = { url: "/tui/show-toast" } +export type TuiShowToastErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiShowToastError = TuiShowToastErrors[keyof TuiShowToastErrors] + export type TuiShowToastResponses = { /** * Toast notification shown successfully @@ -6479,6 +7001,15 @@ export type TuiControlNextData = { url: "/tui/control/next" } +export type TuiControlNextErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiControlNextError = TuiControlNextErrors[keyof TuiControlNextErrors] + export type TuiControlNextResponses = { /** * Next TUI request @@ -6501,6 +7032,15 @@ export type TuiControlResponseData = { url: "/tui/control/response" } +export type TuiControlResponseErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type TuiControlResponseError = TuiControlResponseErrors[keyof TuiControlResponseErrors] + export type TuiControlResponseResponses = { /** * Response submitted successfully @@ -6520,6 +7060,16 @@ export type ExperimentalWorkspaceAdapterListData = { url: "/experimental/workspace/adapter" } +export type ExperimentalWorkspaceAdapterListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceAdapterListError = + ExperimentalWorkspaceAdapterListErrors[keyof ExperimentalWorkspaceAdapterListErrors] + export type ExperimentalWorkspaceAdapterListResponses = { /** * Workspace adapters @@ -6544,6 +7094,15 @@ export type ExperimentalWorkspaceListData = { url: "/experimental/workspace" } +export type ExperimentalWorkspaceListErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceListError = ExperimentalWorkspaceListErrors[keyof ExperimentalWorkspaceListErrors] + export type ExperimentalWorkspaceListResponses = { /** * Workspaces @@ -6559,7 +7118,7 @@ export type ExperimentalWorkspaceCreateData = { id?: string type: string branch: string | null - extra?: unknown | null + extra: unknown | null } path?: never query?: { @@ -6599,6 +7158,16 @@ export type ExperimentalWorkspaceStatusData = { url: "/experimental/workspace/status" } +export type ExperimentalWorkspaceStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceStatusError = + ExperimentalWorkspaceStatusErrors[keyof ExperimentalWorkspaceStatusErrors] + export type ExperimentalWorkspaceStatusResponses = { /** * Workspace status @@ -6644,41 +7213,37 @@ export type ExperimentalWorkspaceRemoveResponses = { export type ExperimentalWorkspaceRemoveResponse = ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] -export type ExperimentalWorkspaceSessionRestoreData = { +export type ExperimentalWorkspaceWarpData = { body?: { + id: string | null sessionID: string } - path: { - id: string - } + path?: never query?: { directory?: string workspace?: string } - url: "/experimental/workspace/{id}/session-restore" + url: "/experimental/workspace/warp" } -export type ExperimentalWorkspaceSessionRestoreErrors = { +export type ExperimentalWorkspaceWarpErrors = { /** * Bad request */ 400: BadRequestError } -export type ExperimentalWorkspaceSessionRestoreError = - ExperimentalWorkspaceSessionRestoreErrors[keyof ExperimentalWorkspaceSessionRestoreErrors] +export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors] -export type ExperimentalWorkspaceSessionRestoreResponses = { +export type ExperimentalWorkspaceWarpResponses = { /** - * Session replay started + * Session warped */ - 200: { - total: number - } + 204: void } -export type ExperimentalWorkspaceSessionRestoreResponse = - ExperimentalWorkspaceSessionRestoreResponses[keyof ExperimentalWorkspaceSessionRestoreResponses] +export type ExperimentalWorkspaceWarpResponse = + ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses] export type PtyConnectData = { body?: never @@ -6693,6 +7258,10 @@ export type PtyConnectData = { } export type PtyConnectErrors = { + /** + * Bad request + */ + 400: BadRequestError /** * Forbidden */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6ff18b5155..1a2f1e9475 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -218,6 +218,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get health information about the OpenCode server.", @@ -245,6 +255,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Subscribe to global events from the OpenCode system using server-sent events.", @@ -272,6 +292,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the current global OpenCode configuration settings and preferences.", @@ -344,6 +374,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Clean up and dispose all OpenCode instances, releasing all resources.", @@ -470,6 +510,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get events", @@ -514,6 +564,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the current OpenCode configuration settings and preferences.", @@ -636,6 +696,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all configured AI providers and their default models.", @@ -680,6 +750,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", @@ -757,6 +837,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get the available Console orgs across logged-in accounts, including the current active org.", @@ -993,6 +1083,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "List all sandbox worktrees for the current project.", @@ -1292,6 +1392,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", @@ -1340,6 +1450,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get all available MCP resources from connected servers. Optionally filter by name.", @@ -1456,6 +1576,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Search for text patterns across files in the project using ripgrep.", @@ -1540,6 +1670,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Search for files or directories by name or pattern in the project directory.", @@ -1596,6 +1736,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Search for workspace symbols like functions, classes, and variables using LSP.", @@ -1652,6 +1802,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "List files and directories in a specified path.", @@ -1704,6 +1864,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Read the content of a specified file.", @@ -1752,6 +1922,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get the git status of all files in the project.", @@ -1797,6 +1977,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", @@ -1841,6 +2031,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the current working directory and related path information for the OpenCode instance.", @@ -1885,6 +2085,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", @@ -1942,6 +2152,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the current git diff for the working tree or against the default branch.", @@ -1990,6 +2210,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all available commands in the OpenCode system.", @@ -2038,6 +2268,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all available AI agents in the OpenCode system.", @@ -2102,6 +2342,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all available skills in the OpenCode system.", @@ -2150,6 +2400,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get LSP server status", @@ -2198,6 +2458,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get formatter status", @@ -2246,6 +2516,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get the status of all Model Context Protocol (MCP) servers.", @@ -2393,7 +2673,7 @@ } }, "400": { - "description": "McpUnsupportedOAuthError", + "description": "McpUnsupportedOAuthError | BadRequest", "content": { "application/json": { "schema": { @@ -2471,6 +2751,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -2622,7 +2912,7 @@ } }, "400": { - "description": "McpUnsupportedOAuthError", + "description": "McpUnsupportedOAuthError | BadRequest", "content": { "application/json": { "schema": { @@ -2693,6 +2983,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Connect an MCP server.", @@ -2745,6 +3045,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Disconnect an MCP server.", @@ -2792,6 +3102,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of projects that have been opened with OpenCode.", @@ -2836,6 +3156,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the currently active project that OpenCode is working with.", @@ -2880,6 +3210,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Create a git repository for the current project and return the refreshed project info.", @@ -3053,6 +3393,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of available shells on the system.", @@ -3101,6 +3451,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", @@ -3240,6 +3600,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -3393,6 +3763,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "404": { "description": "Not found", "content": { @@ -3468,6 +3848,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "403": { "description": "Forbidden", "content": { @@ -3535,6 +3925,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get all pending question requests across all sessions.", @@ -3751,6 +4151,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get all pending permission requests across all sessions.", @@ -3912,6 +4322,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all available AI providers, including both available and connected ones.", @@ -3963,6 +4383,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve available authentication methods for all AI providers.", @@ -4236,6 +4666,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", @@ -4852,6 +5292,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get the file changes (diff) that resulted from a specific user message in the session.", @@ -5342,6 +5792,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Create a new session by forking an existing session at a specific message point.", @@ -6668,6 +7128,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Start sync loops for workspaces in the current project that have active sessions.", @@ -6771,7 +7241,85 @@ } } }, - "required": ["directory", "events"], + "required": ["directory", "events"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.replay({\n ...\n})" + } + ] + } + }, + "/sync/steal": { + "post": { + "tags": ["sync"], + "operationId": "sync.steal", + "parameters": [ + { + "name": "directory", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "workspace", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session stolen into workspace", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], + "additionalProperties": false, + "description": "Session stolen into workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Update a session to belong to the current workspace through the sync event system.", + "summary": "Steal session into workspace", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"], "additionalProperties": false } } @@ -6780,7 +7328,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.replay({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.steal({\n ...\n})" } ] } @@ -6971,6 +7519,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Create a v2 session message and queue it for the agent loop.", @@ -7036,6 +7594,16 @@ "responses": { "204": { "description": "" + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Compact a v2 session conversation.", @@ -7082,6 +7650,16 @@ "responses": { "204": { "description": "" + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Wait for a v2 session agent loop to become idle.", @@ -7138,6 +7716,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the active context messages for a v2 session (all messages after the last compaction).", @@ -7317,6 +7905,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Open the help dialog in the TUI to display user assistance information.", @@ -7362,6 +7960,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Open the session dialog.", @@ -7407,6 +8015,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Open the theme dialog.", @@ -7452,6 +8070,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Open the model dialog.", @@ -7497,6 +8125,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Submit the prompt.", @@ -7542,6 +8180,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Clear the prompt.", @@ -7658,6 +8306,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Show a toast notification in the TUI.", @@ -7897,6 +8555,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Retrieve the next TUI request from the queue for processing.", @@ -7942,6 +8610,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Submit a response to the TUI request queue to complete a pending request.", @@ -8010,6 +8688,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "List all available workspace adapters for the current project.", @@ -8058,6 +8746,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "List all workspaces.", @@ -8145,7 +8843,7 @@ ] } }, - "required": ["type", "branch"], + "required": ["type", "branch", "extra"], "additionalProperties": false } } @@ -8206,6 +8904,16 @@ } } } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } } }, "description": "Get connection status for workspaces in the current project.", @@ -8281,10 +8989,10 @@ ] } }, - "/experimental/workspace/{id}/session-restore": { + "/experimental/workspace/warp": { "post": { "tags": ["workspace"], - "operationId": "experimental.workspace.sessionRestore", + "operationId": "experimental.workspace.warp", "parameters": [ { "name": "directory", @@ -8301,36 +9009,11 @@ "schema": { "type": "string" } - }, - { - "name": "id", - "in": "path", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true } ], "responses": { - "200": { - "description": "Session replay started", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "total": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["total"], - "additionalProperties": false, - "description": "Session replay started" - } - } - } + "204": { + "description": "Session warped" }, "400": { "description": "Bad request", @@ -8343,19 +9026,22 @@ } } }, - "description": "Replay a session's sync events into the target workspace in batches.", - "summary": "Restore session into workspace", + "description": "Move a session's sync history into the target workspace, or detach it to the local project.", + "summary": "Warp session into workspace", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { + "id": { + "type": "string" + }, "sessionID": { "type": "string" } }, - "required": ["sessionID"], + "required": ["id", "sessionID"], "additionalProperties": false } } @@ -8364,7 +9050,7 @@ "x-codeSamples": [ { "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.warp({\n ...\n})" } ] } @@ -8412,6 +9098,16 @@ } } }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, "403": { "description": "Forbidden", "content": { @@ -8538,9 +9234,6 @@ { "$ref": "#/components/schemas/EventWorkspaceFailed" }, - { - "$ref": "#/components/schemas/EventWorkspaceRestore" - }, { "$ref": "#/components/schemas/EventWorkspaceStatus" }, @@ -10737,9 +11430,6 @@ { "$ref": "#/components/schemas/EventWorkspaceFailed" }, - { - "$ref": "#/components/schemas/EventWorkspaceRestore" - }, { "$ref": "#/components/schemas/EventWorkspaceStatus" }, @@ -15793,41 +16483,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventWorkspaceRestore": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["workspace.restore"] - }, - "properties": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string" - }, - "sessionID": { - "type": "string" - }, - "total": { - "type": "integer", - "minimum": 0 - }, - "step": { - "type": "integer", - "minimum": 0 - } - }, - "required": ["workspaceID", "sessionID", "total", "step"], - "additionalProperties": false - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "EventWorkspaceStatus": { "type": "object", "properties": { From f33b17e8ac157237fdf3c4d3ff06ced126fb4752 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 5 May 2026 01:29:49 +0000 Subject: [PATCH 157/178] chore: generate --- packages/sdk/js/src/v2/gen/sdk.gen.ts | 207 +++----- packages/sdk/js/src/v2/gen/types.gen.ts | 562 +------------------- packages/sdk/openapi.json | 658 +----------------------- 3 files changed, 73 insertions(+), 1354 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index ab191b0566..ffc0970c0e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -4,84 +4,58 @@ import { client } from "./client.gen.js" import { buildClientParams, type Client, type Options as Options2, type TDataShape } from "./client/index.js" import type { AgentPartInput, - AppAgentsErrors, AppAgentsResponses, AppLogErrors, AppLogResponses, - AppSkillsErrors, AppSkillsResponses, Auth as Auth3, AuthRemoveErrors, AuthRemoveResponses, AuthSetErrors, AuthSetResponses, - CommandListErrors, CommandListResponses, Config as Config3, - ConfigGetErrors, ConfigGetResponses, - ConfigProvidersErrors, ConfigProvidersResponses, ConfigUpdateErrors, ConfigUpdateResponses, - EventSubscribeErrors, EventSubscribeResponses, EventTuiCommandExecute2, EventTuiPromptAppend2, EventTuiSessionSelect2, EventTuiToastShow2, - ExperimentalConsoleGetErrors, ExperimentalConsoleGetResponses, - ExperimentalConsoleListOrgsErrors, ExperimentalConsoleListOrgsResponses, ExperimentalConsoleSwitchOrgResponses, - ExperimentalResourceListErrors, ExperimentalResourceListResponses, - ExperimentalSessionListErrors, ExperimentalSessionListResponses, - ExperimentalWorkspaceAdapterListErrors, ExperimentalWorkspaceAdapterListResponses, ExperimentalWorkspaceCreateErrors, ExperimentalWorkspaceCreateResponses, - ExperimentalWorkspaceListErrors, ExperimentalWorkspaceListResponses, ExperimentalWorkspaceRemoveErrors, ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceStatusErrors, ExperimentalWorkspaceStatusResponses, ExperimentalWorkspaceWarpErrors, ExperimentalWorkspaceWarpResponses, - FileListErrors, FileListResponses, FilePartInput, FilePartSource, - FileReadErrors, FileReadResponses, - FileStatusErrors, FileStatusResponses, - FindFilesErrors, FindFilesResponses, - FindSymbolsErrors, FindSymbolsResponses, - FindTextErrors, FindTextResponses, - FormatterStatusErrors, FormatterStatusResponses, - GlobalConfigGetErrors, GlobalConfigGetResponses, GlobalConfigUpdateErrors, GlobalConfigUpdateResponses, - GlobalDisposeErrors, GlobalDisposeResponses, - GlobalEventErrors, GlobalEventResponses, - GlobalHealthErrors, GlobalHealthResponses, GlobalUpgradeErrors, GlobalUpgradeResponses, - InstanceDisposeErrors, InstanceDisposeResponses, - LspStatusErrors, LspStatusResponses, McpAddErrors, McpAddResponses, @@ -93,13 +67,10 @@ import type { McpAuthRemoveResponses, McpAuthStartErrors, McpAuthStartResponses, - McpConnectErrors, McpConnectResponses, - McpDisconnectErrors, McpDisconnectResponses, McpLocalConfig, McpRemoteConfig, - McpStatusErrors, McpStatusResponses, OutputFormat, Part as Part2, @@ -107,27 +78,20 @@ import type { PartDeleteResponses, PartUpdateErrors, PartUpdateResponses, - PathGetErrors, PathGetResponses, - PermissionListErrors, PermissionListResponses, PermissionReplyErrors, PermissionReplyResponses, PermissionRespondErrors, PermissionRespondResponses, PermissionRuleset, - ProjectCurrentErrors, ProjectCurrentResponses, - ProjectInitGitErrors, ProjectInitGitResponses, - ProjectListErrors, ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, Prompt, - ProviderAuthErrors, ProviderAuthResponses, - ProviderListErrors, ProviderListResponses, ProviderOauthAuthorizeErrors, ProviderOauthAuthorizeResponses, @@ -141,16 +105,13 @@ import type { PtyCreateResponses, PtyGetErrors, PtyGetResponses, - PtyListErrors, PtyListResponses, PtyRemoveErrors, PtyRemoveResponses, - PtyShellsErrors, PtyShellsResponses, PtyUpdateErrors, PtyUpdateResponses, QuestionAnswer, - QuestionListErrors, QuestionListResponses, QuestionRejectErrors, QuestionRejectResponses, @@ -169,15 +130,12 @@ import type { SessionDeleteMessageResponses, SessionDeleteResponses, SessionDelivery, - SessionDiffErrors, SessionDiffResponses, - SessionForkErrors, SessionForkResponses, SessionGetErrors, SessionGetResponses, SessionInitErrors, SessionInitResponses, - SessionListErrors, SessionListResponses, SessionMessageErrors, SessionMessageResponses, @@ -210,7 +168,6 @@ import type { SyncHistoryListResponses, SyncReplayErrors, SyncReplayResponses, - SyncStartErrors, SyncStartResponses, SyncStealErrors, SyncStealResponses, @@ -221,50 +178,34 @@ import type { ToolListResponses, TuiAppendPromptErrors, TuiAppendPromptResponses, - TuiClearPromptErrors, TuiClearPromptResponses, - TuiControlNextErrors, TuiControlNextResponses, - TuiControlResponseErrors, TuiControlResponseResponses, TuiExecuteCommandErrors, TuiExecuteCommandResponses, - TuiOpenHelpErrors, TuiOpenHelpResponses, - TuiOpenModelsErrors, TuiOpenModelsResponses, - TuiOpenSessionsErrors, TuiOpenSessionsResponses, - TuiOpenThemesErrors, TuiOpenThemesResponses, TuiPublishErrors, TuiPublishResponses, TuiSelectSessionErrors, TuiSelectSessionResponses, - TuiShowToastErrors, TuiShowToastResponses, - TuiSubmitPromptErrors, TuiSubmitPromptResponses, - V2SessionCompactErrors, V2SessionCompactResponses, - V2SessionContextErrors, V2SessionContextResponses, V2SessionListErrors, V2SessionListResponses, V2SessionMessagesErrors, V2SessionMessagesResponses, - V2SessionPromptErrors, V2SessionPromptResponses, - V2SessionWaitErrors, V2SessionWaitResponses, - VcsDiffErrors, VcsDiffResponses, - VcsGetErrors, VcsGetResponses, WorktreeCreateErrors, WorktreeCreateInput, WorktreeCreateResponses, - WorktreeListErrors, WorktreeListResponses, WorktreeRemoveErrors, WorktreeRemoveInput, @@ -442,7 +383,7 @@ export class App extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/agent", ...options, ...params, @@ -472,7 +413,7 @@ export class App extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/skill", ...options, ...params, @@ -487,7 +428,7 @@ export class Config extends HeyApiClient { * Retrieve the current global OpenCode configuration settings and preferences. */ public get(options?: Options) { - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/global/config", ...options, }) @@ -525,7 +466,7 @@ export class Global extends HeyApiClient { * Get health information about the OpenCode server. */ public health(options?: Options) { - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/global/health", ...options, }) @@ -537,7 +478,7 @@ export class Global extends HeyApiClient { * Subscribe to global events from the OpenCode system using server-sent events. */ public event(options?: Options) { - return (options?.client ?? this.client).sse.get({ + return (options?.client ?? this.client).sse.get({ url: "/global/event", ...options, }) @@ -549,7 +490,7 @@ export class Global extends HeyApiClient { * Clean up and dispose all OpenCode instances, releasing all resources. */ public dispose(options?: Options) { - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/global/dispose", ...options, }) @@ -609,7 +550,7 @@ export class Event extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).sse.get({ + return (options?.client ?? this.client).sse.get({ url: "/event", ...options, ...params, @@ -641,7 +582,7 @@ export class Config2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/config", ...options, ...params, @@ -708,7 +649,7 @@ export class Config2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/config/providers", ...options, ...params, @@ -740,11 +681,7 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalConsoleGetResponses, - ExperimentalConsoleGetErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/console", ...options, ...params, @@ -774,11 +711,7 @@ export class Console extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalConsoleListOrgsResponses, - ExperimentalConsoleListOrgsErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/console/orgs", ...options, ...params, @@ -861,11 +794,7 @@ export class Session extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalSessionListResponses, - ExperimentalSessionListErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/session", ...options, ...params, @@ -897,11 +826,7 @@ export class Resource extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalResourceListResponses, - ExperimentalResourceListErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/resource", ...options, ...params, @@ -933,11 +858,7 @@ export class Adapter extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalWorkspaceAdapterListResponses, - ExperimentalWorkspaceAdapterListErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/workspace/adapter", ...options, ...params, @@ -969,11 +890,7 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalWorkspaceListResponses, - ExperimentalWorkspaceListErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/workspace", ...options, ...params, @@ -1050,11 +967,7 @@ export class Workspace extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get< - ExperimentalWorkspaceStatusResponses, - ExperimentalWorkspaceStatusErrors, - ThrowOnError - >({ + return (options?.client ?? this.client).get({ url: "/experimental/workspace/status", ...options, ...params, @@ -1106,7 +1019,7 @@ export class Workspace extends HeyApiClient { parameters?: { directory?: string workspace?: string - id?: string | null + id?: string sessionID?: string }, options?: Options, @@ -1295,7 +1208,7 @@ export class Worktree extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/experimental/worktree", ...options, ...params, @@ -1403,7 +1316,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find", ...options, ...params, @@ -1441,7 +1354,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find/file", ...options, ...params, @@ -1473,7 +1386,7 @@ export class Find extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/find/symbol", ...options, ...params, @@ -1507,7 +1420,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file", ...options, ...params, @@ -1539,7 +1452,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file/content", ...options, ...params, @@ -1569,7 +1482,7 @@ export class File extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/file/status", ...options, ...params, @@ -1601,7 +1514,7 @@ export class Instance extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/instance/dispose", ...options, ...params, @@ -1633,7 +1546,7 @@ export class Path extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/path", ...options, ...params, @@ -1665,7 +1578,7 @@ export class Vcs extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/vcs", ...options, ...params, @@ -1697,7 +1610,7 @@ export class Vcs extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/vcs/diff", ...options, ...params, @@ -1729,7 +1642,7 @@ export class Command extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/command", ...options, ...params, @@ -1761,7 +1674,7 @@ export class Lsp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/lsp", ...options, ...params, @@ -1793,7 +1706,7 @@ export class Formatter extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/formatter", ...options, ...params, @@ -1964,7 +1877,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/mcp", ...options, ...params, @@ -2033,7 +1946,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/mcp/{name}/connect", ...options, ...params, @@ -2063,7 +1976,7 @@ export class Mcp extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/mcp/{name}/disconnect", ...options, ...params, @@ -2100,7 +2013,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/project", ...options, ...params, @@ -2130,7 +2043,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/project/current", ...options, ...params, @@ -2160,7 +2073,7 @@ export class Project extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/project/git/init", ...options, ...params, @@ -2244,7 +2157,7 @@ export class Pty extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/pty/shells", ...options, ...params, @@ -2274,7 +2187,7 @@ export class Pty extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/pty", ...options, ...params, @@ -2525,7 +2438,7 @@ export class Question extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/question", ...options, ...params, @@ -2628,7 +2541,7 @@ export class Permission extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/permission", ...options, ...params, @@ -2838,7 +2751,7 @@ export class Provider extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/provider", ...options, ...params, @@ -2868,7 +2781,7 @@ export class Provider extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/provider/auth", ...options, ...params, @@ -2917,7 +2830,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/session", ...options, ...params, @@ -3205,7 +3118,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/session/{sessionID}/diff", ...options, ...params, @@ -3407,7 +3320,7 @@ export class Session2 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/session/{sessionID}/fork", ...options, ...params, @@ -3983,7 +3896,7 @@ export class Sync extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/sync/start", ...options, ...params, @@ -4148,7 +4061,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/prompt", ...options, ...params, @@ -4185,7 +4098,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/compact", ...options, ...params, @@ -4217,7 +4130,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/api/session/{sessionID}/wait", ...options, ...params, @@ -4249,7 +4162,7 @@ export class Session3 extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/api/session/{sessionID}/context", ...options, ...params, @@ -4320,7 +4233,7 @@ export class Control extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).get({ + return (options?.client ?? this.client).get({ url: "/tui/control/next", ...options, ...params, @@ -4352,7 +4265,7 @@ export class Control extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/control/response", ...options, ...params, @@ -4426,7 +4339,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-help", ...options, ...params, @@ -4456,7 +4369,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-sessions", ...options, ...params, @@ -4486,7 +4399,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-themes", ...options, ...params, @@ -4516,7 +4429,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/open-models", ...options, ...params, @@ -4546,7 +4459,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/submit-prompt", ...options, ...params, @@ -4576,7 +4489,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/clear-prompt", ...options, ...params, @@ -4651,7 +4564,7 @@ export class Tui extends HeyApiClient { }, ], ) - return (options?.client ?? this.client).post({ + return (options?.client ?? this.client).post({ url: "/tui/show-toast", ...options, ...params, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a40b567f8c..c0255754d9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3345,15 +3345,6 @@ export type GlobalHealthData = { url: "/global/health" } -export type GlobalHealthErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type GlobalHealthError = GlobalHealthErrors[keyof GlobalHealthErrors] - export type GlobalHealthResponses = { /** * Health information @@ -3373,15 +3364,6 @@ export type GlobalEventData = { url: "/global/event" } -export type GlobalEventErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type GlobalEventError = GlobalEventErrors[keyof GlobalEventErrors] - export type GlobalEventResponses = { /** * Event stream @@ -3398,15 +3380,6 @@ export type GlobalConfigGetData = { url: "/global/config" } -export type GlobalConfigGetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type GlobalConfigGetError = GlobalConfigGetErrors[keyof GlobalConfigGetErrors] - export type GlobalConfigGetResponses = { /** * Get global config info @@ -3448,15 +3421,6 @@ export type GlobalDisposeData = { url: "/global/dispose" } -export type GlobalDisposeErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type GlobalDisposeError = GlobalDisposeErrors[keyof GlobalDisposeErrors] - export type GlobalDisposeResponses = { /** * Global disposed @@ -3511,15 +3475,6 @@ export type EventSubscribeData = { url: "/event" } -export type EventSubscribeErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type EventSubscribeError = EventSubscribeErrors[keyof EventSubscribeErrors] - export type EventSubscribeResponses = { /** * Event stream @@ -3539,15 +3494,6 @@ export type ConfigGetData = { url: "/config" } -export type ConfigGetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ConfigGetError = ConfigGetErrors[keyof ConfigGetErrors] - export type ConfigGetResponses = { /** * Get config info @@ -3595,15 +3541,6 @@ export type ConfigProvidersData = { url: "/config/providers" } -export type ConfigProvidersErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ConfigProvidersError = ConfigProvidersErrors[keyof ConfigProvidersErrors] - export type ConfigProvidersResponses = { /** * List of providers @@ -3628,15 +3565,6 @@ export type ExperimentalConsoleGetData = { url: "/experimental/console" } -export type ExperimentalConsoleGetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalConsoleGetError = ExperimentalConsoleGetErrors[keyof ExperimentalConsoleGetErrors] - export type ExperimentalConsoleGetResponses = { /** * Active Console provider metadata @@ -3656,16 +3584,6 @@ export type ExperimentalConsoleListOrgsData = { url: "/experimental/console/orgs" } -export type ExperimentalConsoleListOrgsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalConsoleListOrgsError = - ExperimentalConsoleListOrgsErrors[keyof ExperimentalConsoleListOrgsErrors] - export type ExperimentalConsoleListOrgsResponses = { /** * Switchable Console orgs @@ -3804,15 +3722,6 @@ export type WorktreeListData = { url: "/experimental/worktree" } -export type WorktreeListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type WorktreeListError = WorktreeListErrors[keyof WorktreeListErrors] - export type WorktreeListResponses = { /** * List of worktree directories @@ -3894,15 +3803,6 @@ export type ExperimentalSessionListData = { url: "/experimental/session" } -export type ExperimentalSessionListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalSessionListError = ExperimentalSessionListErrors[keyof ExperimentalSessionListErrors] - export type ExperimentalSessionListResponses = { /** * List of sessions @@ -3922,15 +3822,6 @@ export type ExperimentalResourceListData = { url: "/experimental/resource" } -export type ExperimentalResourceListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalResourceListError = ExperimentalResourceListErrors[keyof ExperimentalResourceListErrors] - export type ExperimentalResourceListResponses = { /** * MCP resources @@ -3954,15 +3845,6 @@ export type FindTextData = { url: "/find" } -export type FindTextErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FindTextError = FindTextErrors[keyof FindTextErrors] - export type FindTextResponses = { /** * Matches @@ -4002,15 +3884,6 @@ export type FindFilesData = { url: "/find/file" } -export type FindFilesErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FindFilesError = FindFilesErrors[keyof FindFilesErrors] - export type FindFilesResponses = { /** * File paths @@ -4031,15 +3904,6 @@ export type FindSymbolsData = { url: "/find/symbol" } -export type FindSymbolsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FindSymbolsError = FindSymbolsErrors[keyof FindSymbolsErrors] - export type FindSymbolsResponses = { /** * Symbols @@ -4060,15 +3924,6 @@ export type FileListData = { url: "/file" } -export type FileListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FileListError = FileListErrors[keyof FileListErrors] - export type FileListResponses = { /** * Files and directories @@ -4089,15 +3944,6 @@ export type FileReadData = { url: "/file/content" } -export type FileReadErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FileReadError = FileReadErrors[keyof FileReadErrors] - export type FileReadResponses = { /** * File content @@ -4117,15 +3963,6 @@ export type FileStatusData = { url: "/file/status" } -export type FileStatusErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FileStatusError = FileStatusErrors[keyof FileStatusErrors] - export type FileStatusResponses = { /** * File status @@ -4145,15 +3982,6 @@ export type InstanceDisposeData = { url: "/instance/dispose" } -export type InstanceDisposeErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type InstanceDisposeError = InstanceDisposeErrors[keyof InstanceDisposeErrors] - export type InstanceDisposeResponses = { /** * Instance disposed @@ -4173,15 +4001,6 @@ export type PathGetData = { url: "/path" } -export type PathGetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type PathGetError = PathGetErrors[keyof PathGetErrors] - export type PathGetResponses = { /** * Path @@ -4201,15 +4020,6 @@ export type VcsGetData = { url: "/vcs" } -export type VcsGetErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type VcsGetError = VcsGetErrors[keyof VcsGetErrors] - export type VcsGetResponses = { /** * VCS info @@ -4230,15 +4040,6 @@ export type VcsDiffData = { url: "/vcs/diff" } -export type VcsDiffErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type VcsDiffError = VcsDiffErrors[keyof VcsDiffErrors] - export type VcsDiffResponses = { /** * VCS diff @@ -4258,15 +4059,6 @@ export type CommandListData = { url: "/command" } -export type CommandListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type CommandListError = CommandListErrors[keyof CommandListErrors] - export type CommandListResponses = { /** * List of commands @@ -4286,15 +4078,6 @@ export type AppAgentsData = { url: "/agent" } -export type AppAgentsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AppAgentsError = AppAgentsErrors[keyof AppAgentsErrors] - export type AppAgentsResponses = { /** * List of agents @@ -4314,15 +4097,6 @@ export type AppSkillsData = { url: "/skill" } -export type AppSkillsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type AppSkillsError = AppSkillsErrors[keyof AppSkillsErrors] - export type AppSkillsResponses = { /** * List of skills @@ -4347,15 +4121,6 @@ export type LspStatusData = { url: "/lsp" } -export type LspStatusErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type LspStatusError = LspStatusErrors[keyof LspStatusErrors] - export type LspStatusResponses = { /** * LSP server status @@ -4375,15 +4140,6 @@ export type FormatterStatusData = { url: "/formatter" } -export type FormatterStatusErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type FormatterStatusError = FormatterStatusErrors[keyof FormatterStatusErrors] - export type FormatterStatusResponses = { /** * Formatter status @@ -4403,15 +4159,6 @@ export type McpStatusData = { url: "/mcp" } -export type McpStatusErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type McpStatusError = McpStatusErrors[keyof McpStatusErrors] - export type McpStatusResponses = { /** * MCP server status @@ -4469,10 +4216,6 @@ export type McpAuthRemoveData = { } export type McpAuthRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * Not found */ @@ -4506,7 +4249,7 @@ export type McpAuthStartData = { export type McpAuthStartErrors = { /** - * McpUnsupportedOAuthError | BadRequest + * McpUnsupportedOAuthError */ 400: McpUnsupportedOAuthError /** @@ -4579,7 +4322,7 @@ export type McpAuthAuthenticateData = { export type McpAuthAuthenticateErrors = { /** - * McpUnsupportedOAuthError | BadRequest + * McpUnsupportedOAuthError */ 400: McpUnsupportedOAuthError /** @@ -4611,15 +4354,6 @@ export type McpConnectData = { url: "/mcp/{name}/connect" } -export type McpConnectErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type McpConnectError = McpConnectErrors[keyof McpConnectErrors] - export type McpConnectResponses = { /** * MCP server connected successfully @@ -4641,15 +4375,6 @@ export type McpDisconnectData = { url: "/mcp/{name}/disconnect" } -export type McpDisconnectErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type McpDisconnectError = McpDisconnectErrors[keyof McpDisconnectErrors] - export type McpDisconnectResponses = { /** * MCP server disconnected successfully @@ -4669,15 +4394,6 @@ export type ProjectListData = { url: "/project" } -export type ProjectListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProjectListError = ProjectListErrors[keyof ProjectListErrors] - export type ProjectListResponses = { /** * List of projects @@ -4697,15 +4413,6 @@ export type ProjectCurrentData = { url: "/project/current" } -export type ProjectCurrentErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProjectCurrentError = ProjectCurrentErrors[keyof ProjectCurrentErrors] - export type ProjectCurrentResponses = { /** * Current project information @@ -4725,15 +4432,6 @@ export type ProjectInitGitData = { url: "/project/git/init" } -export type ProjectInitGitErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProjectInitGitError = ProjectInitGitErrors[keyof ProjectInitGitErrors] - export type ProjectInitGitResponses = { /** * Project information after git initialization @@ -4800,15 +4498,6 @@ export type PtyShellsData = { url: "/pty/shells" } -export type PtyShellsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type PtyShellsError = PtyShellsErrors[keyof PtyShellsErrors] - export type PtyShellsResponses = { /** * List of shells @@ -4832,15 +4521,6 @@ export type PtyListData = { url: "/pty" } -export type PtyListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type PtyListError = PtyListErrors[keyof PtyListErrors] - export type PtyListResponses = { /** * List of sessions @@ -4899,10 +4579,6 @@ export type PtyRemoveData = { } export type PtyRemoveErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * Not found */ @@ -4933,10 +4609,6 @@ export type PtyGetData = { } export type PtyGetErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * Not found */ @@ -5003,10 +4675,6 @@ export type PtyConnectTokenData = { } export type PtyConnectTokenErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * Forbidden */ @@ -5041,15 +4709,6 @@ export type QuestionListData = { url: "/question" } -export type QuestionListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type QuestionListError = QuestionListErrors[keyof QuestionListErrors] - export type QuestionListResponses = { /** * List of pending questions @@ -5142,15 +4801,6 @@ export type PermissionListData = { url: "/permission" } -export type PermissionListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type PermissionListError = PermissionListErrors[keyof PermissionListErrors] - export type PermissionListResponses = { /** * List of pending permissions @@ -5207,15 +4857,6 @@ export type ProviderListData = { url: "/provider" } -export type ProviderListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderListError = ProviderListErrors[keyof ProviderListErrors] - export type ProviderListResponses = { /** * List of providers @@ -5241,15 +4882,6 @@ export type ProviderAuthData = { url: "/provider/auth" } -export type ProviderAuthErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ProviderAuthError2 = ProviderAuthErrors[keyof ProviderAuthErrors] - export type ProviderAuthResponses = { /** * Provider auth methods @@ -5351,15 +4983,6 @@ export type SessionListData = { url: "/session" } -export type SessionListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type SessionListError = SessionListErrors[keyof SessionListErrors] - export type SessionListResponses = { /** * List of sessions @@ -5627,15 +5250,6 @@ export type SessionDiffData = { url: "/session/{sessionID}/diff" } -export type SessionDiffErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type SessionDiffError = SessionDiffErrors[keyof SessionDiffErrors] - export type SessionDiffResponses = { /** * Successfully retrieved diff @@ -5823,15 +5437,6 @@ export type SessionForkData = { url: "/session/{sessionID}/fork" } -export type SessionForkErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type SessionForkError = SessionForkErrors[keyof SessionForkErrors] - export type SessionForkResponses = { /** * 200 @@ -6355,15 +5960,6 @@ export type SyncStartData = { url: "/sync/start" } -export type SyncStartErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type SyncStartError = SyncStartErrors[keyof SyncStartErrors] - export type SyncStartResponses = { /** * Workspace sync started @@ -6527,15 +6123,6 @@ export type V2SessionPromptData = { url: "/api/session/{sessionID}/prompt" } -export type V2SessionPromptErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type V2SessionPromptError = V2SessionPromptErrors[keyof V2SessionPromptErrors] - export type V2SessionPromptResponses = { /** * Session.Message @@ -6557,15 +6144,6 @@ export type V2SessionCompactData = { url: "/api/session/{sessionID}/compact" } -export type V2SessionCompactErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type V2SessionCompactError = V2SessionCompactErrors[keyof V2SessionCompactErrors] - export type V2SessionCompactResponses = { /** * @@ -6587,15 +6165,6 @@ export type V2SessionWaitData = { url: "/api/session/{sessionID}/wait" } -export type V2SessionWaitErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type V2SessionWaitError = V2SessionWaitErrors[keyof V2SessionWaitErrors] - export type V2SessionWaitResponses = { /** * @@ -6617,15 +6186,6 @@ export type V2SessionContextData = { url: "/api/session/{sessionID}/context" } -export type V2SessionContextErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type V2SessionContextError = V2SessionContextErrors[keyof V2SessionContextErrors] - export type V2SessionContextResponses = { /** * Success @@ -6705,15 +6265,6 @@ export type TuiOpenHelpData = { url: "/tui/open-help" } -export type TuiOpenHelpErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiOpenHelpError = TuiOpenHelpErrors[keyof TuiOpenHelpErrors] - export type TuiOpenHelpResponses = { /** * Help dialog opened successfully @@ -6733,15 +6284,6 @@ export type TuiOpenSessionsData = { url: "/tui/open-sessions" } -export type TuiOpenSessionsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiOpenSessionsError = TuiOpenSessionsErrors[keyof TuiOpenSessionsErrors] - export type TuiOpenSessionsResponses = { /** * Session dialog opened successfully @@ -6761,15 +6303,6 @@ export type TuiOpenThemesData = { url: "/tui/open-themes" } -export type TuiOpenThemesErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiOpenThemesError = TuiOpenThemesErrors[keyof TuiOpenThemesErrors] - export type TuiOpenThemesResponses = { /** * Theme dialog opened successfully @@ -6789,15 +6322,6 @@ export type TuiOpenModelsData = { url: "/tui/open-models" } -export type TuiOpenModelsErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiOpenModelsError = TuiOpenModelsErrors[keyof TuiOpenModelsErrors] - export type TuiOpenModelsResponses = { /** * Model dialog opened successfully @@ -6817,15 +6341,6 @@ export type TuiSubmitPromptData = { url: "/tui/submit-prompt" } -export type TuiSubmitPromptErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiSubmitPromptError = TuiSubmitPromptErrors[keyof TuiSubmitPromptErrors] - export type TuiSubmitPromptResponses = { /** * Prompt submitted successfully @@ -6845,15 +6360,6 @@ export type TuiClearPromptData = { url: "/tui/clear-prompt" } -export type TuiClearPromptErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiClearPromptError = TuiClearPromptErrors[keyof TuiClearPromptErrors] - export type TuiClearPromptResponses = { /** * Prompt cleared successfully @@ -6908,15 +6414,6 @@ export type TuiShowToastData = { url: "/tui/show-toast" } -export type TuiShowToastErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiShowToastError = TuiShowToastErrors[keyof TuiShowToastErrors] - export type TuiShowToastResponses = { /** * Toast notification shown successfully @@ -7001,15 +6498,6 @@ export type TuiControlNextData = { url: "/tui/control/next" } -export type TuiControlNextErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiControlNextError = TuiControlNextErrors[keyof TuiControlNextErrors] - export type TuiControlNextResponses = { /** * Next TUI request @@ -7032,15 +6520,6 @@ export type TuiControlResponseData = { url: "/tui/control/response" } -export type TuiControlResponseErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type TuiControlResponseError = TuiControlResponseErrors[keyof TuiControlResponseErrors] - export type TuiControlResponseResponses = { /** * Response submitted successfully @@ -7060,16 +6539,6 @@ export type ExperimentalWorkspaceAdapterListData = { url: "/experimental/workspace/adapter" } -export type ExperimentalWorkspaceAdapterListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceAdapterListError = - ExperimentalWorkspaceAdapterListErrors[keyof ExperimentalWorkspaceAdapterListErrors] - export type ExperimentalWorkspaceAdapterListResponses = { /** * Workspace adapters @@ -7094,15 +6563,6 @@ export type ExperimentalWorkspaceListData = { url: "/experimental/workspace" } -export type ExperimentalWorkspaceListErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceListError = ExperimentalWorkspaceListErrors[keyof ExperimentalWorkspaceListErrors] - export type ExperimentalWorkspaceListResponses = { /** * Workspaces @@ -7118,7 +6578,7 @@ export type ExperimentalWorkspaceCreateData = { id?: string type: string branch: string | null - extra: unknown | null + extra?: unknown | null } path?: never query?: { @@ -7158,16 +6618,6 @@ export type ExperimentalWorkspaceStatusData = { url: "/experimental/workspace/status" } -export type ExperimentalWorkspaceStatusErrors = { - /** - * Bad request - */ - 400: BadRequestError -} - -export type ExperimentalWorkspaceStatusError = - ExperimentalWorkspaceStatusErrors[keyof ExperimentalWorkspaceStatusErrors] - export type ExperimentalWorkspaceStatusResponses = { /** * Workspace status @@ -7215,7 +6665,7 @@ export type ExperimentalWorkspaceRemoveResponse = export type ExperimentalWorkspaceWarpData = { body?: { - id: string | null + id: string sessionID: string } path?: never @@ -7258,10 +6708,6 @@ export type PtyConnectData = { } export type PtyConnectErrors = { - /** - * Bad request - */ - 400: BadRequestError /** * Forbidden */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1a2f1e9475..db8889f1a4 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -218,16 +218,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get health information about the OpenCode server.", @@ -255,16 +245,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Subscribe to global events from the OpenCode system using server-sent events.", @@ -292,16 +272,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the current global OpenCode configuration settings and preferences.", @@ -374,16 +344,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Clean up and dispose all OpenCode instances, releasing all resources.", @@ -510,16 +470,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get events", @@ -564,16 +514,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the current OpenCode configuration settings and preferences.", @@ -696,16 +636,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all configured AI providers and their default models.", @@ -750,16 +680,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get the active Console org name and the set of provider IDs managed by that Console org.", @@ -837,16 +757,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get the available Console orgs across logged-in accounts, including the current active org.", @@ -1083,16 +993,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "List all sandbox worktrees for the current project.", @@ -1392,16 +1292,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", @@ -1450,16 +1340,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get all available MCP resources from connected servers. Optionally filter by name.", @@ -1576,16 +1456,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Search for text patterns across files in the project using ripgrep.", @@ -1670,16 +1540,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Search for files or directories by name or pattern in the project directory.", @@ -1736,16 +1596,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Search for workspace symbols like functions, classes, and variables using LSP.", @@ -1802,16 +1652,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "List files and directories in a specified path.", @@ -1864,16 +1704,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Read the content of a specified file.", @@ -1922,16 +1752,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get the git status of all files in the project.", @@ -1977,16 +1797,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Clean up and dispose the current OpenCode instance, releasing all resources.", @@ -2031,16 +1841,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the current working directory and related path information for the OpenCode instance.", @@ -2085,16 +1885,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve version control system (VCS) information for the current project, such as git branch.", @@ -2152,16 +1942,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the current git diff for the working tree or against the default branch.", @@ -2210,16 +1990,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all available commands in the OpenCode system.", @@ -2268,16 +2038,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all available AI agents in the OpenCode system.", @@ -2342,16 +2102,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all available skills in the OpenCode system.", @@ -2400,16 +2150,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get LSP server status", @@ -2458,16 +2198,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get formatter status", @@ -2516,16 +2246,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get the status of all Model Context Protocol (MCP) servers.", @@ -2673,7 +2393,7 @@ } }, "400": { - "description": "McpUnsupportedOAuthError | BadRequest", + "description": "McpUnsupportedOAuthError", "content": { "application/json": { "schema": { @@ -2751,16 +2471,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "404": { "description": "Not found", "content": { @@ -2912,7 +2622,7 @@ } }, "400": { - "description": "McpUnsupportedOAuthError | BadRequest", + "description": "McpUnsupportedOAuthError", "content": { "application/json": { "schema": { @@ -2983,16 +2693,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Connect an MCP server.", @@ -3045,16 +2745,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Disconnect an MCP server.", @@ -3102,16 +2792,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of projects that have been opened with OpenCode.", @@ -3156,16 +2836,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the currently active project that OpenCode is working with.", @@ -3210,16 +2880,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Create a git repository for the current project and return the refreshed project info.", @@ -3393,16 +3053,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of available shells on the system.", @@ -3451,16 +3101,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all active pseudo-terminal (PTY) sessions managed by OpenCode.", @@ -3600,16 +3240,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "404": { "description": "Not found", "content": { @@ -3763,16 +3393,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "404": { "description": "Not found", "content": { @@ -3838,22 +3458,12 @@ }, "expires_in": { "type": "integer", - "exclusiveMinimum": 0 - } - }, - "required": ["ticket", "expires_in"], - "additionalProperties": false, - "description": "WebSocket connect token" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" + "exclusiveMinimum": 0 + } + }, + "required": ["ticket", "expires_in"], + "additionalProperties": false, + "description": "WebSocket connect token" } } } @@ -3925,16 +3535,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get all pending question requests across all sessions.", @@ -4151,16 +3751,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get all pending permission requests across all sessions.", @@ -4322,16 +3912,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all available AI providers, including both available and connected ones.", @@ -4383,16 +3963,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve available authentication methods for all AI providers.", @@ -4666,16 +4236,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get a list of all OpenCode sessions, sorted by most recently updated.", @@ -5292,16 +4852,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get the file changes (diff) that resulted from a specific user message in the session.", @@ -5792,16 +5342,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Create a new session by forking an existing session at a specific message point.", @@ -7128,16 +6668,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Start sync loops for workspaces in the current project that have active sessions.", @@ -7519,16 +7049,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Create a v2 session message and queue it for the agent loop.", @@ -7594,16 +7114,6 @@ "responses": { "204": { "description": "" - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Compact a v2 session conversation.", @@ -7650,16 +7160,6 @@ "responses": { "204": { "description": "" - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Wait for a v2 session agent loop to become idle.", @@ -7716,16 +7216,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the active context messages for a v2 session (all messages after the last compaction).", @@ -7905,16 +7395,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Open the help dialog in the TUI to display user assistance information.", @@ -7960,16 +7440,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Open the session dialog.", @@ -8015,16 +7485,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Open the theme dialog.", @@ -8070,16 +7530,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Open the model dialog.", @@ -8125,16 +7575,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Submit the prompt.", @@ -8180,16 +7620,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Clear the prompt.", @@ -8306,16 +7736,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Show a toast notification in the TUI.", @@ -8555,16 +7975,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Retrieve the next TUI request from the queue for processing.", @@ -8610,16 +8020,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Submit a response to the TUI request queue to complete a pending request.", @@ -8688,16 +8088,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "List all available workspace adapters for the current project.", @@ -8746,16 +8136,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "List all workspaces.", @@ -8843,7 +8223,7 @@ ] } }, - "required": ["type", "branch", "extra"], + "required": ["type", "branch"], "additionalProperties": false } } @@ -8904,16 +8284,6 @@ } } } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } } }, "description": "Get connection status for workspaces in the current project.", @@ -9098,16 +8468,6 @@ } } }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - }, "403": { "description": "Forbidden", "content": { From 2740d398fa26df560eeb0566226004677c84d0c2 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 5 May 2026 11:37:18 +1000 Subject: [PATCH 158/178] devex: Enable Electron MCP servers with DevTools debug port (#25795) --- packages/desktop-electron/src/main/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index c9f16606ae..af7fd42583 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -74,6 +74,7 @@ setupApp() function setupApp() { ensureLoopbackNoProxy() app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>") + if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222") if (!app.requestSingleInstanceLock()) { app.quit() From edd480f56be832bd3daa871b5bbb6c124bc10a4e Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 4 May 2026 22:06:33 -0400 Subject: [PATCH 159/178] fix(tui): fix type error for calling workspace.warp (#25801) --- .../src/cli/cmd/tui/component/dialog-workspace-create.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx index e2af0d63e1..ad40637575 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-create.tsx @@ -77,7 +77,7 @@ export async function warpWorkspaceSession(input: { }): Promise { const result = await input.sdk.client.experimental.workspace .warp({ - id: input.workspaceID, + id: input.workspaceID ?? undefined, sessionID: input.sessionID, }) .catch(() => undefined) From f6a3615f59e51dec879a7f8d0cce584b05d4c9e2 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 5 May 2026 10:15:00 +0800 Subject: [PATCH 160/178] fix(console): remove Cloudflare cache config from download fetch (#25804) --- .../app/src/routes/download/[channel]/[platform].ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/console/app/src/routes/download/[channel]/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts index 4ae8e2465f..7a4b5ef65e 100644 --- a/packages/console/app/src/routes/download/[channel]/[platform].ts +++ b/packages/console/app/src/routes/download/[channel]/[platform].ts @@ -32,13 +32,6 @@ export async function GET({ params: { platform, channel } }: APIEvent) { const resp = await fetch( `https://github.com/anomalyco/${channel === "stable" ? "opencode" : "opencode-beta"}/releases/latest/download/${assetName}`, - { - cf: { - // in case gh releases has rate limits - cacheTtl: 60 * 5, - cacheEverything: true, - }, - } as any, ) const downloadName = downloadNames[platform] From 0df2bb0f3b29b8b98d80c0bd3b1d5c8aac21098f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 4 May 2026 22:22:39 -0400 Subject: [PATCH 161/178] docs: restore v2 todo --- specs/v2/todo.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 specs/v2/todo.md diff --git a/specs/v2/todo.md b/specs/v2/todo.md new file mode 100644 index 0000000000..3a4b9cf241 --- /dev/null +++ b/specs/v2/todo.md @@ -0,0 +1,59 @@ +# TODO + +ok we need to work towards a launch of v2 so we can get out of this rebuild phase + +## Kill Hono - Kit + +Hono needs to go away so zod can go away. this is almost done + +## New Data Mode - Dax + +This is mostly done. I'm working through modeling subagents, skill invocations +and shell commands. + +## Rework agent loop - Kit? + +I think this needs to be done so we can take advantage of the simpler data +model. It can stop doing all the + +## Rework compaction - Aiden? + +The new agent loop needs to trigger compaction properly + +## Plugin API design - ??? + +We need to figure out how we want server plugins to work and what hooks are useful. + +Some ideas: + +- plugins get immer drafts so bad mutations can be thrown away +- plugins get global "opencode" instance like in that post i showed +- opencode instance has stuff like `opencode.session.prompt()` or + `opencode.tool.register({...})` + +## Rework Config - ??? + +We should do another pass on config to clean up any mistakes we made with it and +simplify as much as possible. Old configs should get auto-converted to new + +## Auth - ??? + +I have a basic auth system that can track any kind of auth, not just providers + +## Model Database - ??? + +I have a basic model service that allows for models to be registered dynamically + +## Provider - ??? + +Providers should register as plugins and autoload based on whatever logic they +want / config. They should register models into model database + +## Event - Kit/James + +I have this v2/event.ts but it needs to be self contained instead of using the +old bus system + +## Everything is hotreloadable - ??? + +Instead of needing to tear down things when something changes every service should emit granular events so services can react to them and reconfigure themselves. Allows frontend to receive these too, eg model.added. also prevents startup from blocking From 39c88f9afb2281ae3df290f4d88acaf2f8e8398b Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 4 May 2026 22:35:21 -0400 Subject: [PATCH 162/178] Improve v2 session message rendering (#25634) --- packages/core/src/global.ts | 2 + .../src/cli/cmd/tui/context/sync-v2.tsx | 16 +- .../tui/feature-plugins/system/session-v2.tsx | 193 +++++++++----- packages/opencode/src/id/id.ts | 1 + packages/opencode/src/session/processor.ts | 9 +- .../opencode/src/session/projectors-next.ts | 6 +- packages/opencode/src/session/prompt.ts | 9 +- packages/opencode/src/v2/auth.ts | 246 ++++++++++++++++++ packages/opencode/src/v2/model.ts | 192 ++++++++++++++ packages/opencode/src/v2/session-event.ts | 23 +- .../src/v2/session-message-updater.ts | 6 +- packages/opencode/src/v2/session-message.ts | 12 +- packages/opencode/src/v2/session.ts | 76 ++++-- .../test/server/httpapi-session.test.ts | 7 +- .../test/v2/session-message-updater.test.ts | 19 +- specs/v2/session-concepts-gap.md | 131 ---------- specs/v2/todo.md | 4 +- 17 files changed, 677 insertions(+), 275 deletions(-) create mode 100644 packages/opencode/src/v2/auth.ts create mode 100644 packages/opencode/src/v2/model.ts delete mode 100644 specs/v2/session-concepts-gap.md diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 1acc3f47f1..6560d308c1 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -71,6 +71,8 @@ export const layer = Layer.effect( Effect.sync(() => Service.of(make())), ) +export const defaultLayer = layer + export const layerWith = (input: Partial) => Layer.effect( Service, diff --git a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx index 9801f0a2f8..d9d23999d2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx @@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" function activeAssistant(messages: SessionMessage[]) { - const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) + const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed) if (index < 0) return const assistant = messages[index] return assistant?.type === "assistant" ? assistant : undefined } function activeCompaction(messages: SessionMessage[]) { - const index = messages.findLastIndex((message) => message.type === "compaction") + const index = messages.findIndex((message) => message.type === "compaction") if (index < 0) return const compaction = messages[index] return compaction?.type === "compaction" ? compaction : undefined } function activeShell(messages: SessionMessage[], callID: string) { - const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) + const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID) if (index < 0) return const shell = messages[index] return shell?.type === "shell" ? shell : undefined @@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( switch (event.type) { case "session.next.prompted": { update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "user", text: event.properties.prompt.text, @@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( } case "session.next.synthetic": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "synthetic", sessionID: event.properties.sessionID, @@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.shell.started": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "shell", callID: event.properties.callID, @@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( update(event.properties.sessionID, (draft) => { const currentAssistant = activeAssistant(draft) if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp - draft.push({ + draft.unshift({ id: event.id, type: "assistant", agent: event.properties.agent, @@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext( break case "session.next.compaction.started": update(event.properties.sessionID, (draft) => { - draft.push({ + draft.unshift({ id: event.id, type: "compaction", reason: event.properties.reason, diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 7270a9c3b7..2e5cea9804 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner" import { useTheme } from "@tui/context/theme" import { useLocal } from "@tui/context/local" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" -import type { SyntaxStyle } from "@opentui/core" +import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core" import { Locale } from "@/util/locale" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import path from "path" @@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) const renderedMessages = createMemo(() => messages().toReversed()) const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) + const lastUserCreated = (index: number) => + renderedMessages() + .slice(0, index) + .findLast((message) => message.type === "user")?.time.created createEffect(() => { void sync.session.message.sync(props.sessionID) @@ -83,10 +87,11 @@ function View(props: { api: TuiPluginApi; sessionID: string }) { last={lastAssistant()?.id === message.id} syntax={syntax()} subtleSyntax={subtleSyntax()} + start={lastUserCreated(index())} /> - + <> @@ -146,63 +151,36 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) { - - - } - > - {props.message.text} - - - - - {(file) => ( - - {file.mime} - {file.name ?? file.uri} - - )} - - - {(agent) => ( - - agent - {agent.name} - - )} - - - - {Locale.todayTimeOrDateTime(props.message.time.created)} - - - ) -} - -function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) { - const { theme } = useTheme() - return ( - - Synthetic {props.message.text} + + + + {(file) => ( + + {file.mime} + {file.name ?? file.uri} + + )} + + + {(agent) => ( + + agent + {agent.name} + + )} + + + ) } @@ -237,7 +215,7 @@ function ShellMessage(props: { message: SessionMessageShell }) { } function CompactionMessage(props: { message: SessionMessageCompaction }) { - const { theme } = useTheme() + const { theme, syntax } = useTheme() return ( - {props.message.summary} + {(summary) => ( + + + + )} ) @@ -294,12 +284,13 @@ function AssistantMessage(props: { last: boolean syntax: SyntaxStyle subtleSyntax: SyntaxStyle + start?: number }) { const { theme } = useTheme() const local = useLocal() const duration = createMemo(() => { if (!props.message.time.completed) return 0 - return props.message.time.completed - props.message.time.created + return props.message.time.completed - (props.start ?? props.message.time.created) }) const model = createMemo(() => { const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" @@ -361,7 +352,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta const { theme } = useTheme() return ( - + (props.part.state.status === "error" ? props.part.state.error.message : undefined)) + const complete = createMemo(() => !!props.complete) const denied = createMemo(() => { const message = error() if (!message) return false return ( message.includes("QuestionRejectedError") || message.includes("rejected permission") || + message.includes("specified a rule") || message.includes("user dismissed") ) }) + const fg = createMemo(() => { + if (error()) return theme.error + if (complete()) return theme.textMuted + return theme.text + }) + const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined)) return ( - - - - {props.children} - - - - ~ {props.pending}} when={props.complete}> - {props.icon} {props.children} - - - - - - {error()} - + error() && setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => { + if (!error()) return + if (renderer.getSelection()?.getSelectedText()) return + setShowError((prev) => !prev) + }} + renderBefore={function () { + const el = this as BoxRenderable + const parent = el.parent + if (!parent) return + const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1] + if (!previous) { + setMargin(0) + return + } + if (previous.id.startsWith("text")) setMargin(1) + }} + > + + + + + + + + {props.icon} + + + + + ~ + + + + + + + + + + {props.children} + + + + + {props.pending} + + + + + + + {error()} + + + ) } diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index 46c210fa5d..6d9a6447a0 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -13,6 +13,7 @@ const prefixes = { tool: "tool", workspace: "wrk", entry: "ent", + account: "act", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index cf1a7e0ae9..f22da92927 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -22,6 +22,7 @@ import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" import { EventV2 } from "@/v2/event" import { SessionEvent } from "@/v2/session-event" +import { Modelv2 } from "@/v2/model" import * as DateTime from "effect/DateTime" const DOOM_LOOP_THRESHOLD = 3 @@ -432,9 +433,9 @@ export const layer: Layer.Layer< sessionID: ctx.sessionID, agent: input.assistantMessage.agent, model: { - id: ctx.model.id, - providerID: ctx.model.providerID, - variant: input.assistantMessage.variant, + id: Modelv2.ID.make(ctx.model.id), + providerID: Modelv2.ProviderID.make(ctx.model.providerID), + variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"), }, snapshot: ctx.snapshot, timestamp: DateTime.makeUnsafe(Date.now()), @@ -655,7 +656,7 @@ export const layer: Layer.Layer< EventV2.run(SessionEvent.Step.Failed.Sync, { sessionID: ctx.sessionID, error: { - type: error.name, + type: "unknown", message: errorMessage(e), }, timestamp: DateTime.makeUnsafe(Date.now()), diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index 88f73acf1a..93298170cc 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -132,11 +132,7 @@ export default [ SyncEvent.project(SessionEvent.ModelSwitched.Sync, (db, data, event) => { db.update(SessionTable) .set({ - model: { - id: data.id, - providerID: data.providerID, - variant: data.variant, - }, + model: data.model, time_updated: DateTime.toEpochMillis(data.timestamp), }) .where(eq(SessionTable.id, data.sessionID)) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0590fc3827..e1fa81abf1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -56,6 +56,7 @@ import { SessionRunState } from "./run-state" import { EffectBridge } from "@/effect/bridge" import { EventV2 } from "@/v2/event" import { SessionEvent } from "@/v2/session-event" +import { Modelv2 } from "@/v2/model" import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" @@ -978,9 +979,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the EventV2.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(info.time.created), - id: info.model.modelID, - providerID: info.model.providerID, - variant: info.model.variant, + model: { + id: Modelv2.ID.make(info.model.modelID), + providerID: Modelv2.ProviderID.make(info.model.providerID), + variant: Modelv2.VariantID.make(info.model.variant ?? "default"), + }, }) } diff --git a/packages/opencode/src/v2/auth.ts b/packages/opencode/src/v2/auth.ts new file mode 100644 index 0000000000..1cc443974d --- /dev/null +++ b/packages/opencode/src/v2/auth.ts @@ -0,0 +1,246 @@ +import path from "path" +import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" +import { Identifier } from "@opencode-ai/core/util/identifier" +import { NonNegativeInt, withStatics } from "@/util/schema" +import { Global } from "@opencode-ai/core/global" +import { AppFileSystem } from "@opencode-ai/core/filesystem" + +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" + +const AccountID = Schema.String.pipe( + Schema.brand("AccountID"), + withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), +) +export type AccountID = typeof AccountID.Type + +export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) +export type ServiceID = typeof ServiceID.Type + +export class OAuthCredential extends Schema.Class("AuthV2.OAuthCredential")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, +}) {} + +export class ApiKeyCredential extends Schema.Class("AuthV2.ApiKeyCredential")({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ + identifier: "AuthV2.Credential", + }) +export type Credential = Schema.Schema.Type + +export class Account extends Schema.Class("AuthV2.Account")({ + id: AccountID, + serviceID: ServiceID, + description: Schema.String, + credential: Credential, +}) {} + +export class AuthFileWriteError extends Schema.TaggedErrorClass()("AuthV2.FileWriteError", { + operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), + cause: Schema.Defect, +}) {} + +export type AuthError = AuthFileWriteError + +interface Writable { + version: 2 + accounts: Record + active: Record +} + +const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) + +function migrate(old: Record): Writable { + const accounts: Record = {} + const active: Record = {} + for (const [serviceID, value] of Object.entries(old)) { + const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) + const parsed = (decoded as Record)[serviceID] + if (!parsed) continue + const id = Identifier.ascending() + const accountID = AccountID.make(id) + const brandedServiceID = ServiceID.make(serviceID) + accounts[id] = new Account({ + id: accountID, + serviceID: brandedServiceID, + description: "default", + credential: parsed, + }) + active[brandedServiceID] = accountID + } + return { version: 2, accounts, active } +} + +export interface Interface { + readonly get: (accountID: AccountID) => Effect.Effect + readonly all: () => Effect.Effect + readonly create: (input: { + serviceID: ServiceID + credential: Credential + description?: string + active?: boolean + }) => Effect.Effect + readonly update: ( + accountID: AccountID, + updates: Partial>, + ) => Effect.Effect + readonly remove: (accountID: AccountID) => Effect.Effect + readonly activate: (accountID: AccountID) => Effect.Effect + readonly active: (serviceID: ServiceID) => Effect.Effect + readonly forService: (serviceID: ServiceID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Auth") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const global = yield* Global.Service + const file = path.join(global.data, "auth-v2.json") + + const load: () => Effect.Effect = Effect.fnUntraced(function* () { + if (process.env.OPENCODE_AUTH_CONTENT) { + try { + return JSON.parse(process.env.OPENCODE_AUTH_CONTENT) + } catch {} + } + + const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + + if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} } + + if ("version" in raw && raw.version === 2) return raw as Writable + + const migrated = migrate(raw as Record) + yield* fsys + .writeJson(file, migrated, 0o600) + .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause }))) + return migrated + }) + + const write = (data: Writable) => + fsys + .writeJson(file, data, 0o600) + .pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause }))) + + const state = SynchronizedRef.makeUnsafe(yield* load()) + + const result: Interface = { + get: Effect.fn("AuthV2.get")(function* (accountID) { + return (yield* SynchronizedRef.get(state)).accounts[accountID] + }), + + all: Effect.fn("AuthV2.all")(function* () { + return Object.values((yield* SynchronizedRef.get(state)).accounts) + }), + + active: Effect.fn("AuthV2.active")(function* (serviceID) { + const data = yield* SynchronizedRef.get(state) + return ( + data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) + ) + }), + + forService: Effect.fn("AuthV2.list")(function* (serviceID) { + return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) + }), + + create: Effect.fn("AuthV2.add")(function* (input) { + return yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const account = new Account({ + id: AccountID.make(Identifier.ascending()), + serviceID: input.serviceID, + description: input.description ?? "default", + credential: input.credential, + }) + const next = { + ...data, + accounts: { ...data.accounts, [account.id]: account }, + active: + (input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID)) + ? { ...data.active, [input.serviceID]: account.id } + : data.active, + } + + yield* write(next) + return [account, next] as const + }), + ) + }), + + update: Effect.fn("AuthV2.update")(function* (accountID, updates) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const existing = data.accounts[accountID] + if (!existing) return [undefined, data] as const + + const next = { + ...data, + accounts: { + ...data.accounts, + [accountID]: new Account({ + id: accountID, + serviceID: existing.serviceID, + description: updates.description ?? existing.description, + credential: updates.credential ?? existing.credential, + }), + }, + } + + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + remove: Effect.fn("AuthV2.remove")(function* (accountID) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const accounts = { ...data.accounts } + const active = { ...data.active } + if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID) + delete active[accounts[accountID].serviceID] + delete accounts[accountID] + + const next = { ...data, accounts, active } + yield* write(next) + return [undefined, next] as const + }), + ) + }), + + activate: Effect.fn("AuthV2.activate")(function* (accountID) { + yield* SynchronizedRef.modifyEffect( + state, + Effect.fnUntraced(function* (data) { + const account = data.accounts[accountID] + if (!account) return [undefined, data] as const + + const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } } + yield* write(next) + return [undefined, next] as const + }), + ) + }), + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer)) + +export * as AuthV2 from "./auth" diff --git a/packages/opencode/src/v2/model.ts b/packages/opencode/src/v2/model.ts new file mode 100644 index 0000000000..db66199a59 --- /dev/null +++ b/packages/opencode/src/v2/model.ts @@ -0,0 +1,192 @@ +import { withStatics } from "@/util/schema" +import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect" +import { DateTimeUtcFromMillis } from "effect/Schema" + +export const ID = Schema.String.pipe(Schema.brand("Model.ID")) +export type ID = typeof ID.Type + +export const ProviderID = Schema.String.pipe( + Schema.brand("Model.ProviderID"), + withStatics((schema) => ({ + // Well-known providers + opencode: schema.make("opencode"), + anthropic: schema.make("anthropic"), + openai: schema.make("openai"), + google: schema.make("google"), + googleVertex: schema.make("google-vertex"), + githubCopilot: schema.make("github-copilot"), + amazonBedrock: schema.make("amazon-bedrock"), + azure: schema.make("azure"), + openrouter: schema.make("openrouter"), + mistral: schema.make("mistral"), + gitlab: schema.make("gitlab"), + })), +) +export type ProviderID = typeof ProviderID.Type + +export const VariantID = Schema.String.pipe(Schema.brand("VariantID")) +export type VariantID = typeof VariantID.Type + +// Grouping of models, eg claude opus, claude sonnet +export const Family = Schema.String.pipe(Schema.brand("Family")) +export type Family = typeof Family.Type + +const OpenAIResponses = Schema.Struct({ + type: Schema.Literal("openai/responses"), + url: Schema.String, + websocket: Schema.optional(Schema.Boolean), +}) + +const OpenAICompletions = Schema.Struct({ + type: Schema.Literal("openai/completions"), + url: Schema.String, + reasoning: Schema.Union([ + Schema.Struct({ + type: Schema.Literal("reasoning_content"), + }), + Schema.Struct({ + type: Schema.Literal("reasoning_details"), + }), + ]).pipe(Schema.optional), +}) +export type OpenAICompletions = typeof OpenAICompletions.Type + +const AnthropicMessages = Schema.Struct({ + type: Schema.Literal("anthropic/messages"), + url: Schema.String, +}) + +export const Endpoint = Schema.Union([OpenAIResponses, OpenAICompletions, AnthropicMessages]).pipe( + Schema.toTaggedUnion("type"), +) +export type Endpoint = typeof Endpoint.Type + +export const Capabilities = Schema.Struct({ + tools: Schema.Boolean, + // mime patterns, image, audio, video/*, text/* + input: Schema.String.pipe(Schema.Array), + output: Schema.String.pipe(Schema.Array), +}) +export type Capabilities = typeof Capabilities.Type + +export const Options = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), +}) +export type Options = typeof Options.Type + +export const Cost = Schema.Struct({ + tier: Schema.Struct({ + type: Schema.Literal("context"), + size: Schema.Int, + }).pipe(Schema.optional), + input: Schema.Finite, + output: Schema.Finite, + cache: Schema.Struct({ + read: Schema.Finite, + write: Schema.Finite, + }), +}) + +export const Ref = Schema.Struct({ + id: ID, + providerID: ProviderID, + variant: VariantID, +}) +export type Ref = typeof Ref.Type + +export class Info extends Schema.Class("Model.Info")({ + id: ID, + providerID: ProviderID, + family: Family.pipe(Schema.optional), + name: Schema.String, + endpoint: Endpoint, + capabilities: Capabilities, + options: Schema.Struct({ + ...Options.fields, + variant: Schema.String.pipe(Schema.optional), + }), + variants: Schema.Struct({ + id: VariantID, + ...Options.fields, + }).pipe(Schema.Array), + time: Schema.Struct({ + released: DateTimeUtcFromMillis, + }), + cost: Cost.pipe(Schema.Array), + status: Schema.Literals(["alpha", "beta", "deprecated", "active"]), + limit: Schema.Struct({ + context: Schema.Int, + input: Schema.Int.pipe(Schema.optional), + output: Schema.Int, + }), +}) {} + +export function parse(input: string): { providerID: ProviderID; modelID: ID } { + const [providerID, ...modelID] = input.split("/") + return { + providerID: ProviderID.make(providerID), + modelID: ID.make(modelID.join("/")), + } +} + +export interface Interface { + readonly get: (providerID: ProviderID, modelID: ID) => Effect.Effect> + readonly add: (model: Info) => Effect.Effect + readonly remove: (providerID: ProviderID, modelID: ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly default: () => Effect.Effect> + readonly small: (provider: ProviderID) => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/v2/Model") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + let models = HashMap.empty() + + function key(providerID: ProviderID, modelID: ID) { + return `${providerID}/${modelID}` + } + + const result: Interface = { + get: Effect.fn("V2Model.get")(function* (providerID, modelID) { + return HashMap.get(models, key(providerID, modelID)) + }), + + add: Effect.fn("V2Model.add")(function* (model) { + models = HashMap.set(models, key(model.providerID, model.id), model) + }), + + remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) { + models = HashMap.remove(models, key(providerID, modelID)) + }), + + all: Effect.fn("V2Model.all")(function* () { + return pipe( + models, + HashMap.toValues, + Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), + ) + }), + + default: Effect.fn("V2Model.default")(function* () { + const all = yield* result.all() + return Option.fromUndefinedOr(all[0]) + }), + + small: Effect.fn("V2Model.small")(function* (providerID) { + const all = yield* result.all() + const match = all.find((model) => model.providerID === providerID && model.id.toLowerCase().includes("small")) + return Option.fromUndefinedOr(match) + }), + } + + return Service.of(result) + }), +) + +export const defaultLayer = layer + +export * as Modelv2 from "./model" diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 47938dcbed..7c768bd551 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -5,8 +5,8 @@ import { FileAttachment, Prompt } from "./session-prompt" import { Schema } from "effect" export { FileAttachment } import { ToolOutput } from "./tool-output" -import { ModelID, ProviderID } from "@/provider/schema" import { V2Schema } from "./schema" +import { Modelv2 } from "./model" export const Source = Schema.Struct({ start: NonNegativeInt, @@ -22,10 +22,13 @@ const Base = { sessionID: SessionID, } -const Error = Schema.Struct({ - type: Schema.String, +export const UnknownError = Schema.Struct({ + type: Schema.Literal("unknown"), message: Schema.String, +}).annotate({ + identifier: "Session.Error.Unknown", }) +export type UnknownError = Schema.Schema.Type export const AgentSwitched = EventV2.define({ type: "session.next.agent.switched", @@ -44,9 +47,7 @@ export const ModelSwitched = EventV2.define({ version: 1, schema: { ...Base, - id: ModelID, - providerID: ProviderID, - variant: Schema.String.pipe(Schema.optional), + model: Modelv2.Ref, }, }) export type ModelSwitched = Schema.Schema.Type @@ -103,11 +104,7 @@ export namespace Step { schema: { ...Base, agent: Schema.String, - model: Schema.Struct({ - id: Schema.String, - providerID: Schema.String, - variant: Schema.String.pipe(Schema.optional), - }), + model: Modelv2.Ref, snapshot: Schema.String.pipe(Schema.optional), }, }) @@ -139,7 +136,7 @@ export namespace Step { aggregate: "sessionID", schema: { ...Base, - error: Error, + error: UnknownError, }, }) export type Failed = Schema.Schema.Type @@ -296,7 +293,7 @@ export namespace Tool { schema: { ...Base, callID: Schema.String, - error: Error, + error: UnknownError, provider: Schema.Struct({ executed: Schema.Boolean, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), diff --git a/packages/opencode/src/v2/session-message-updater.ts b/packages/opencode/src/v2/session-message-updater.ts index d5d5aac7b7..80ecb1011e 100644 --- a/packages/opencode/src/v2/session-message-updater.ts +++ b/packages/opencode/src/v2/session-message-updater.ts @@ -109,11 +109,7 @@ export function update(adapter: Adapter, event: SessionEvent.Eve id: event.id, type: "model-switched", metadata: event.metadata, - model: { - id: event.data.id, - providerID: event.data.providerID, - variant: event.data.variant, - }, + model: event.data.model, time: { created: event.data.timestamp }, }), ) diff --git a/packages/opencode/src/v2/session-message.ts b/packages/opencode/src/v2/session-message.ts index 94f6b1cac2..024e28c450 100644 --- a/packages/opencode/src/v2/session-message.ts +++ b/packages/opencode/src/v2/session-message.ts @@ -4,6 +4,7 @@ import { SessionEvent } from "./session-event" import { EventV2 } from "./event" import { ToolOutput } from "./tool-output" import { V2Schema } from "./schema" +import { Modelv2 } from "./model" export const ID = EventV2.ID export type ID = Schema.Schema.Type @@ -25,11 +26,7 @@ export class AgentSwitched extends Schema.Class("Session.Message. export class ModelSwitched extends Schema.Class("Session.Message.ModelSwitched")({ ...Base, type: Schema.Literal("model-switched"), - model: Schema.Struct({ - id: SessionEvent.ModelSwitched.fields.data.fields.id, - providerID: SessionEvent.ModelSwitched.fields.data.fields.providerID, - variant: SessionEvent.ModelSwitched.fields.data.fields.variant, - }), + model: Modelv2.Ref, }) {} export class User extends Schema.Class("Session.Message.User")({ @@ -87,10 +84,7 @@ export class ToolStateError extends Schema.Class("Session.Messag input: Schema.Record(Schema.String, Schema.Unknown), content: ToolOutput.Content.pipe(Schema.Array), structured: ToolOutput.Structured, - error: Schema.Struct({ - type: Schema.String, - message: Schema.String, - }), + error: SessionEvent.UnknownError, }) {} export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 1f4cbcf1e0..bb86f039b2 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -3,17 +3,17 @@ import { SessionID } from "@/session/schema" import { WorkspaceID } from "@/control-plane/schema" import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" import * as Database from "@/storage/db" -import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { Context, DateTime, Effect, Layer, Option, Schema } from "effect" import { SessionMessage } from "./session-message" import type { Prompt } from "./session-prompt" import { EventV2 } from "./event" import { ProjectID } from "@/project/schema" -import { ModelID, ProviderID } from "@/provider/schema" import { SessionEvent } from "./session-event" import { V2Schema } from "./schema" import { optionalOmitUndefined } from "@/util/schema" +import { Modelv2 } from "./model" -export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({ +export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ identifier: "Session.Delivery", }) export type Delivery = Schema.Schema.Type @@ -27,11 +27,7 @@ export class Info extends Schema.Class("Session.Info")({ workspaceID: optionalOmitUndefined(WorkspaceID), path: optionalOmitUndefined(Schema.String), agent: optionalOmitUndefined(Schema.String), - model: Schema.Struct({ - id: ModelID, - providerID: ProviderID, - variant: optionalOmitUndefined(Schema.String), - }).pipe(optionalOmitUndefined), + model: Modelv2.Ref.pipe(optionalOmitUndefined), time: Schema.Struct({ created: V2Schema.DateTimeUtcFromMillis, updated: V2Schema.DateTimeUtcFromMillis, @@ -53,7 +49,18 @@ export class Info extends Schema.Class("Session.Info")({ */ }) {} +export class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { + sessionID: SessionID, +}) {} + export interface Interface { + readonly create: (input?: { + agent?: string + model?: Modelv2.Ref + parentID?: SessionID + workspaceID?: WorkspaceID + }) => Effect.Effect + readonly get: (sessionID: SessionID) => Effect.Effect readonly list: (input: { limit?: number order?: "asc" | "desc" @@ -88,13 +95,15 @@ export interface Interface { }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect + readonly subagent: (input: { + id?: EventV2.ID + parentID: SessionID + prompt: Prompt + agent: string + model?: Modelv2.Ref + }) => Effect.Effect readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect - readonly switchModel: (input: { - sessionID: SessionID - id: ModelID - providerID: ProviderID - variant?: string - }) => Effect.Effect + readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => Effect.Effect readonly compact: (sessionID: SessionID) => Effect.Effect readonly wait: (sessionID: SessionID) => Effect.Effect } @@ -120,9 +129,9 @@ export const layer = Layer.effect( agent: row.agent ?? undefined, model: row.model ? { - id: ModelID.make(row.model.id), - providerID: ProviderID.make(row.model.providerID), - variant: row.model.variant, + id: Modelv2.ID.make(row.model.id), + providerID: Modelv2.ProviderID.make(row.model.providerID), + variant: Modelv2.VariantID.make(row.model.variant ?? "default"), } : undefined, time: { @@ -134,6 +143,14 @@ export const layer = Layer.effect( } const result: Interface = { + create: Effect.fn("V2Session.create")(function* (_input) { + return {} as any + }), + get: Effect.fn("V2Session.get")(function* (sessionID) { + const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) + if (!row) return yield* new NotFoundError({ sessionID }) + return fromRow(row) + }), list: Effect.fn("V2Session.list")(function* (input) { const direction = input.cursor?.direction ?? "next" let order = input.order ?? "desc" @@ -262,10 +279,29 @@ export const layer = Layer.effect( EventV2.run(SessionEvent.ModelSwitched.Sync, { sessionID: input.sessionID, timestamp: DateTime.makeUnsafe(Date.now()), - id: input.id, - providerID: input.providerID, - variant: input.variant, + model: input.model, + }) + }), + subagent: Effect.fn("V2Session.subagent")(function* (input) { + const parent = yield* result.get(input.parentID) + const session = yield* result.create({ + agent: input.agent, + model: input.model, + parentID: input.parentID, + workspaceID: parent.workspaceID, + }) + yield* result.prompt({ + prompt: input.prompt, + sessionID: session.id, }) + yield* Effect.gen(function* () { + yield* result.wait(session.id) + const messages = yield* result.messages({ sessionID: session.id, order: "desc" }) + const assistant = messages.find((msg) => msg.type === "assistant") + if (!assistant) return + const text = assistant.content.findLast((part) => part.type === "text") + if (!text) return + }).pipe(Effect.forkChild()) }), compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}), wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}), diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index c9a0b53bb4..34cecd80d0 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -19,6 +19,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" import { SessionMessageTable, SessionTable } from "@/session/session.sql" import { SessionMessage } from "../../src/v2/session-message" +import { Modelv2 } from "../../src/v2/model" import * as DateTime from "effect/DateTime" import * as Log from "@opencode-ai/core/util/log" import { eq } from "drizzle-orm" @@ -214,7 +215,11 @@ describe("session HttpApi", () => { id: SessionMessage.ID.create(), type: "assistant", agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, time: { created: DateTime.makeUnsafe(1) }, content: [], }) diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 128177167c..44ac031eda 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test" import * as DateTime from "effect/DateTime" import { SessionID } from "../../src/session/schema" import { EventV2 } from "../../src/v2/event" +import { Modelv2 } from "../../src/v2/model" import { SessionEvent } from "../../src/v2/session-event" import { SessionMessageUpdater } from "../../src/v2/session-message-updater" @@ -16,7 +17,11 @@ test("step snapshots carry over to assistant messages", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, snapshot: "before", }, } satisfies SessionEvent.Event) @@ -56,7 +61,11 @@ test("text ended populates assistant text content", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, }, } satisfies SessionEvent.Event) @@ -96,7 +105,11 @@ test("tool completion stores completed timestamp", () => { sessionID, timestamp: DateTime.makeUnsafe(1), agent: "build", - model: { id: "model", providerID: "provider" }, + model: { + id: Modelv2.ID.make("model"), + providerID: Modelv2.ProviderID.make("provider"), + variant: Modelv2.VariantID.make("default"), + }, }, } satisfies SessionEvent.Event) diff --git a/specs/v2/session-concepts-gap.md b/specs/v2/session-concepts-gap.md deleted file mode 100644 index 20d84c8f47..0000000000 --- a/specs/v2/session-concepts-gap.md +++ /dev/null @@ -1,131 +0,0 @@ -# Session V2 Concept Gaps - -Compared with `packages/opencode/src/session/message-v2.ts` and `packages/opencode/src/session/processor.ts`, `packages/opencode/src/v2` currently captures the rough event stream for prompts, assistant steps, text, reasoning, tools, retries, and compaction, but it does not yet capture several persisted-message and processor concepts. - -## Message Metadata - -- User messages are missing selected `agent`, `model`, `system`, enabled `tools`, output `format`, and summary metadata. -- Assistant messages are missing `parentID`, `agent`, `providerID`, `modelID`, `variant`, `path.cwd`, `path.root`, deprecated `mode`, `summary`, `structured`, `finish`, and typed `error`. - -## Output Format - -- Text output format. -- JSON-schema output format. -- Structured-output retry count. -- Structured assistant result payload. -- Structured-output error classification. - -## Errors - -- Aborted error. -- Provider auth error. -- API error with status, retryability, headers, body, and metadata. -- Context-overflow error. -- Output-length error. -- Unknown error. -- V2 mostly reduces assistant errors to strings, except retry errors. - -## Part Identity - -- V1 has stable `MessageID`, `PartID`, `sessionID`, and `messageID` on every part. -- V2 assistant content does not preserve stable per-content IDs. -- Stable content IDs matter for deltas, updates, removals, sync events, and UI reconciliation. - -## Part Timing And Metadata - -- V1 text, reasoning, and tool states carry timing and provider metadata. -- V2 assistant text and reasoning content only store text. -- V2 events include metadata, but `SessionEntry` currently drops most provider metadata. - -## Snapshots And Patches - -- Snapshot parts. -- Patch parts. -- Step-start snapshot references. -- Step-finish snapshot references. -- Processor behavior that tracks a snapshot before the stream and emits patches after step finish or cleanup. - -## Step Boundaries - -- V1 stores `step-start` and `step-finish` as first-class parts. -- V2 has `step.started` and `step.ended` events, but the assistant entry only stores aggregate cost and tokens. -- V2 does not preserve step boundary parts, finish reason, or snapshot details in the entry model. - -## Compaction - -- V1 compaction parts have `auto`, `overflow`, and `tail_start_id`. -- V2 compacted events have `auto` and optional `overflow`, but no retained-tail marker. -- V1 also has history filtering semantics around completed summary messages and retained tails. - -## Files And Sources - -- V1 file parts have `mime`, `filename`, `url`, and typed source information. -- V1 source variants include file, symbol, and resource sources. -- Symbol sources include LSP range, name, and kind. -- Resource sources include client name and URI. -- V2 file attachments have `uri`, `mime`, `name`, `description`, and a generic text source, but lose source type, LSP metadata, and resource metadata. - -## Agents And Subtasks - -- Agent parts. -- Subtask parts. -- Subtask prompt, description, agent, model, and command. -- V2 has agent attachments on prompts, but no assistant/session content equivalent for subtask execution. - -## Text Flags - -- Synthetic text flag. -- Ignored text flag. -- V2 has a separate synthetic entry, but no ignored text concept. - -## Tool Calls - -- V1 pending tool state stores parsed input and raw input text separately. -- V2 pending tool state stores a string input but does not preserve a separate raw field. -- V1 completed tool state has `time.start`, `time.end`, and optional `time.compacted`. -- V2 tool time has `created`, `ran`, `completed`, and `pruned`, but the stepper currently does not set `completed` or `pruned`. -- V1 error tool state has `time.start` and `time.end`. -- V1 supports interrupted tool errors with `metadata.interrupted` and preserved partial output. -- V1 tracks provider execution and provider call metadata. -- V2 events include provider info, but `SessionEntryStepper` drops it from entries. -- V1 has tool-output compaction and truncation behavior via `time.compacted`. - -## Media Handling - -- V1 models tool attachments as file parts and has provider-specific handling for media in tool results. -- V1 can strip media, inject synthetic user messages for unsupported providers, and uses a synthetic attachment prompt. -- V2 has attachments but not these model-message conversion semantics. - -## Retries - -- V1 stores retries as independently addressable retry parts. -- V2 stores retries as an assistant aggregate. -- V2 captures some retry information, but not the independent part identity/update model. - -## Processor Control Flow - -- Session status transitions: busy, retry, and idle. -- Retry policy integration. -- Context-overflow-driven compaction. -- Abort and interrupt handling. -- Permission-denied blocking. -- Doom-loop detection. -- Plugin hook for `experimental.text.complete`. -- Background summary generation after steps. -- Cleanup semantics for open text, reasoning, and tool calls. - -## Sync And Bus Events - -- Message updated. -- Message removed. -- Message part updated. -- Message part delta. -- Message part removed. -- V2 has domain events, but not the sync/bus event model for persisted message and part updates/removals. - -## History Retrieval - -- Cursor encoding and decoding. -- Paged message retrieval. -- Reverse streaming through history. -- Compaction-aware history filtering. diff --git a/specs/v2/todo.md b/specs/v2/todo.md index 3a4b9cf241..77c650e55f 100644 --- a/specs/v2/todo.md +++ b/specs/v2/todo.md @@ -20,7 +20,7 @@ model. It can stop doing all the The new agent loop needs to trigger compaction properly -## Plugin API design - ??? +## Plugin API design - James? We need to figure out how we want server plugins to work and what hooks are useful. @@ -49,7 +49,7 @@ I have a basic model service that allows for models to be registered dynamically Providers should register as plugins and autoload based on whatever logic they want / config. They should register models into model database -## Event - Kit/James +## Event - Kit I have this v2/event.ts but it needs to be self contained instead of using the old bus system From 75d141b574b94e304c5222daecd4aa68bb9df1e8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 22:36:06 -0400 Subject: [PATCH 163/178] fix(session): cancel subtask child sessions (#25798) --- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/tool/task.ts | 27 +- packages/opencode/test/session/prompt.test.ts | 37 ++ packages/opencode/test/tool/task.test.ts | 474 ++++++++++-------- 4 files changed, 317 insertions(+), 225 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e1fa81abf1..8286ecf8e6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,5 @@ import path from "path" import os from "os" -import z from "zod" import * as EffectZod from "@/util/effect-zod" import { SessionID, MessageID, PartID } from "./schema" import { MessageV2 } from "./message-v2" @@ -121,9 +120,8 @@ export const layer = Layer.effect( return yield* EffectBridge.make() }) const ops = Effect.fn("SessionPrompt.ops")(function* () { - const run = yield* runner() return { - cancel: (sessionID: SessionID) => run.fork(cancel(sessionID)), + cancel: (sessionID: SessionID) => cancel(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), prompt: (input: PromptInput) => prompt(input), } satisfies TaskPromptOps diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index e58ea9b122..22e4e5671c 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -6,10 +6,11 @@ import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import type { SessionPrompt } from "../session/prompt" import { Config } from "@/config/config" -import { Effect, Schema } from "effect" +import { Effect, Exit, Schema } from "effect" +import { EffectBridge } from "@/effect/bridge" export interface TaskPromptOps { - cancel(sessionID: SessionID): void + cancel(sessionID: SessionID): Effect.Effect resolvePromptParts(template: string): Effect.Effect prompt(input: SessionPrompt.PromptInput): Effect.Effect } @@ -118,16 +119,18 @@ export const TaskTool = Tool.define( const ops = ctx.extra?.promptOps as TaskPromptOps if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra")) + const runCancel = yield* EffectBridge.make() const messageID = MessageID.ascending() + const cancel = ops.cancel(nextSession.id) - function cancel() { - ops.cancel(nextSession.id) + function onAbort() { + runCancel.fork(cancel) } return yield* Effect.acquireUseRelease( Effect.sync(() => { - ctx.abort.addEventListener("abort", cancel) + ctx.abort.addEventListener("abort", onAbort) }), () => Effect.gen(function* () { @@ -163,10 +166,16 @@ export const TaskTool = Tool.define( ].join("\n"), } }), - () => - Effect.sync(() => { - ctx.abort.removeEventListener("abort", cancel) - }), + (_, exit) => + Effect.gen(function* () { + if (Exit.hasInterrupts(exit)) yield* cancel + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.abort.removeEventListener("abort", onAbort) + }), + ), + ), ) }) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index a602c0c8d7..c5170f3464 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -858,6 +858,43 @@ it.live( 30_000, ) +it.live( + "cancel propagates from slash command subtask to child session", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + const tool = taskMsg ? toolPart(taskMsg.parts) : undefined + const sessionID = tool?.state.status === "running" ? tool.state.metadata?.sessionId : undefined + expect(typeof sessionID).toBe("string") + if (typeof sessionID !== "string") throw new Error("missing child session id") + const childID = SessionID.make(sessionID) + expect((yield* status.get(childID)).type).toBe("busy") + + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + expect((yield* status.get(chat.id)).type).toBe("idle") + expect((yield* status.get(childID)).type).toBe("idle") + }), + { git: true, config: providerCfg }, + ), + 10_000, +) + it.live( "cancel with queued callers resolves all cleanly", () => diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index a8d62bb68c..f75fcf84b8 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,18 +1,17 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { Config } from "@/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Instance } from "../../src/project/instance" import { Session } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import type { SessionPrompt } from "../../src/session/prompt" -import { MessageID, PartID } from "../../src/session/schema" +import { MessageID, PartID, SessionID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" -import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" +import { disposeAllInstances } from "../fixture/fixture" import { testEffect } from "../lib/effect" afterEach(async () => { @@ -35,6 +34,14 @@ const it = testEffect( ), ) +function defer() { + let resolve!: (value: T | PromiseLike) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { const session = yield* Session.Service const chat = yield* session.create({ title }) @@ -66,7 +73,7 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { return { - cancel() {}, + cancel: () => Effect.void, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => Effect.sync(() => { @@ -107,189 +114,270 @@ function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithPa } describe("tool.task", () => { - it.live("description sorts subagents by name and is stable across calls", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const agent = yield* Agent.Service - const build = yield* agent.get("build") - const registry = yield* ToolRegistry.Service - const get = Effect.fnUntraced(function* () { - const tools = yield* registry.tools({ ...ref, agent: build }) - return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" - }) - const first = yield* get() - const second = yield* get() + it.instance( + "description sorts subagents by name and is stable across calls", + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const get = Effect.fnUntraced(function* () { + const tools = yield* registry.tools({ ...ref, agent: build }) + return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" + }) + const first = yield* get() + const second = yield* get() - expect(first).toBe(second) + expect(first).toBe(second) - const alpha = first.indexOf("- alpha: Alpha agent") - const explore = first.indexOf("- explore:") - const general = first.indexOf("- general:") - const zebra = first.indexOf("- zebra: Zebra agent") + const alpha = first.indexOf("- alpha: Alpha agent") + const explore = first.indexOf("- explore:") + const general = first.indexOf("- general:") + const zebra = first.indexOf("- zebra: Zebra agent") - expect(alpha).toBeGreaterThan(-1) - expect(explore).toBeGreaterThan(alpha) - expect(general).toBeGreaterThan(explore) - expect(zebra).toBeGreaterThan(general) - }), - { - config: { - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", - }, + expect(alpha).toBeGreaterThan(-1) + expect(explore).toBeGreaterThan(alpha) + expect(general).toBeGreaterThan(explore) + expect(zebra).toBeGreaterThan(general) + }), + { + config: { + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", }, }, }, - ), + }, ) - it.live("description hides denied subagents for the caller", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const agent = yield* Agent.Service - const build = yield* agent.get("build") - const registry = yield* ToolRegistry.Service - const description = - (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" + it.instance( + "description hides denied subagents for the caller", + () => + Effect.gen(function* () { + const agent = yield* Agent.Service + const build = yield* agent.get("build") + const registry = yield* ToolRegistry.Service + const description = + (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" - expect(description).toContain("- alpha: Alpha agent") - expect(description).not.toContain("- zebra: Zebra agent") - }), - { - config: { - permission: { - task: { - "*": "allow", - zebra: "deny", - }, + expect(description).toContain("- alpha: Alpha agent") + expect(description).not.toContain("- zebra: Zebra agent") + }), + { + config: { + permission: { + task: { + "*": "allow", + zebra: "deny", }, - agent: { - zebra: { - description: "Zebra agent", - mode: "subagent", - }, - alpha: { - description: "Alpha agent", - mode: "subagent", - }, + }, + agent: { + zebra: { + description: "Zebra agent", + mode: "subagent", + }, + alpha: { + description: "Alpha agent", + mode: "subagent", }, }, }, - ), + }, ) - it.live("execute resumes an existing task session from task_id", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const sessions = yield* Session.Service - const { chat, assistant } = yield* seed() - const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) - const tool = yield* TaskTool - const def = yield* tool.init() - let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) + it.instance("execute resumes an existing task session from task_id", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) - const result = yield* def.execute( + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(child.id) + expect(result.metadata.sessionId).toBe(child.id) + expect(result.output).toContain(`task_id: ${child.id}`) + expect(seen?.sessionID).toBe(child.id) + }), + ) + + it.instance("execute asks by default and skips checks when bypassed", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const calls: unknown[] = [] + const promptOps = stubOps() + + const exec = (extra?: Record) => + def.execute( { description: "inspect bug", prompt: "look into the cache key path", subagent_type: "general", - task_id: child.id, }, { sessionID: chat.id, messageID: assistant.id, agent: "build", abort: new AbortController().signal, - extra: { promptOps }, + extra: { promptOps, ...extra }, messages: [], metadata: () => Effect.void, - ask: () => Effect.void, + ask: (input) => + Effect.sync(() => { + calls.push(input) + }), }, ) - const kids = yield* sessions.children(chat.id) - expect(kids).toHaveLength(1) - expect(kids[0]?.id).toBe(child.id) - expect(result.metadata.sessionId).toBe(child.id) - expect(result.output).toContain(`task_id: ${child.id}`) - expect(seen?.sessionID).toBe(child.id) - }), - ), - ) + yield* exec() + yield* exec({ bypassAgentCheck: true }) - it.live("execute asks by default and skips checks when bypassed", () => - provideTmpdirInstance(() => - Effect.gen(function* () { - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - const calls: unknown[] = [] - const promptOps = stubOps() - - const exec = (extra?: Record) => - def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps, ...extra }, - messages: [], - metadata: () => Effect.void, - ask: (input) => - Effect.sync(() => { - calls.push(input) - }), - }, - ) + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + permission: "task", + patterns: ["general"], + always: ["*"], + metadata: { + description: "inspect bug", + subagent_type: "general", + }, + }) + }), + ) - yield* exec() - yield* exec({ bypassAgentCheck: true }) + it.instance("execute cancels child session when abort signal fires", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const ready = defer() + const cancelled = defer() + const abort = new AbortController() + const promptOps: TaskPromptOps = { + cancel: (sessionID) => + Effect.sync(() => { + cancelled.resolve(sessionID) + }), + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.promise(() => { + ready.resolve(input) + return cancelled.promise + }).pipe(Effect.as(reply(input, "cancelled"))), + } - expect(calls).toHaveLength(1) - expect(calls[0]).toEqual({ - permission: "task", - patterns: ["general"], - always: ["*"], - metadata: { + const fiber = yield* def + .execute( + { description: "inspect bug", + prompt: "look into the cache key path", subagent_type: "general", }, - }) - }), - ), + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: abort.signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.forkChild) + + const input = yield* Effect.promise(() => ready.promise) + abort.abort() + expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + }), + ) + + it.instance("execute creates a child when task_id does not exist", () => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) + + const result = yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: "ses_missing", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const kids = yield* sessions.children(chat.id) + expect(kids).toHaveLength(1) + expect(kids[0]?.id).toBe(result.metadata.sessionId) + expect(result.metadata.sessionId).not.toBe("ses_missing") + expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) + expect(seen?.sessionID).toBe(result.metadata.sessionId) + }), ) - it.live("execute creates a child when task_id does not exist", () => - provideTmpdirInstance(() => + it.instance( + "execute shapes child permissions for task, todowrite, and primary tools", + () => Effect.gen(function* () { const sessions = yield* Session.Service const { chat, assistant } = yield* seed() const tool = yield* TaskTool const def = yield* tool.init() let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) + const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) const result = yield* def.execute( { description: "inspect bug", prompt: "look into the cache key path", - subagent_type: "general", - task_id: "ses_missing", + subagent_type: "reviewer", }, { sessionID: chat.id, @@ -303,85 +391,45 @@ describe("tool.task", () => { }, ) - const kids = yield* sessions.children(chat.id) - expect(kids).toHaveLength(1) - expect(kids[0]?.id).toBe(result.metadata.sessionId) - expect(result.metadata.sessionId).not.toBe("ses_missing") - expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`) - expect(seen?.sessionID).toBe(result.metadata.sessionId) + const child = yield* sessions.get(result.metadata.sessionId) + expect(child.parentID).toBe(chat.id) + expect(child.permission).toEqual([ + { + permission: "todowrite", + pattern: "*", + action: "deny", + }, + { + permission: "bash", + pattern: "*", + action: "allow", + }, + { + permission: "read", + pattern: "*", + action: "allow", + }, + ]) + expect(seen?.tools).toEqual({ + todowrite: false, + bash: false, + read: false, + }) }), - ), - ) - - it.live("execute shapes child permissions for task, todowrite, and primary tools", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const sessions = yield* Session.Service - const { chat, assistant } = yield* seed() - const tool = yield* TaskTool - const def = yield* tool.init() - let seen: SessionPrompt.PromptInput | undefined - const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) - - const result = yield* def.execute( - { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "reviewer", - }, - { - sessionID: chat.id, - messageID: assistant.id, - agent: "build", - abort: new AbortController().signal, - extra: { promptOps }, - messages: [], - metadata: () => Effect.void, - ask: () => Effect.void, - }, - ) - - const child = yield* sessions.get(result.metadata.sessionId) - expect(child.parentID).toBe(chat.id) - expect(child.permission).toEqual([ - { - permission: "todowrite", - pattern: "*", - action: "deny", - }, - { - permission: "bash", - pattern: "*", - action: "allow", - }, - { - permission: "read", - pattern: "*", - action: "allow", + { + config: { + agent: { + reviewer: { + mode: "subagent", + permission: { + task: "allow", }, - ]) - expect(seen?.tools).toEqual({ - todowrite: false, - bash: false, - read: false, - }) - }), - { - config: { - agent: { - reviewer: { - mode: "subagent", - permission: { - task: "allow", - }, - }, - }, - experimental: { - primary_tools: ["bash", "read"], }, }, + experimental: { + primary_tools: ["bash", "read"], + }, }, - ), + }, ) }) From 2d0a757eb2dbeabad64af02a9fb3602d4ccefd5b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 5 May 2026 02:37:07 +0000 Subject: [PATCH 164/178] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 61 +++++----- packages/sdk/openapi.json | 146 +++++++++--------------- 2 files changed, 83 insertions(+), 124 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index c0255754d9..7734ca53eb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1875,9 +1875,11 @@ export type SyncEventSessionNextModelSwitched = { data: { timestamp: number sessionID: string - id: string - providerID: string - variant?: string + model: { + id: string + providerID: string + variant: string + } } } @@ -1948,7 +1950,7 @@ export type SyncEventSessionNextStepStarted = { model: { id: string providerID: string - variant?: string + variant: string } snapshot?: string } @@ -1987,10 +1989,7 @@ export type SyncEventSessionNextStepFailed = { data: { timestamp: number sessionID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown } } @@ -2188,10 +2187,7 @@ export type SyncEventSessionNextToolFailed = { timestamp: number sessionID: string callID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown provider: { executed: boolean metadata?: { @@ -2616,9 +2612,11 @@ export type EventSessionNextModelSwitched = { properties: { timestamp: number sessionID: string - id: string - providerID: string - variant?: string + model: { + id: string + providerID: string + variant: string + } } } @@ -2693,7 +2691,7 @@ export type EventSessionNextStepStarted = { model: { id: string providerID: string - variant?: string + variant: string } snapshot?: string } @@ -2720,16 +2718,18 @@ export type EventSessionNextStepEnded = { } } +export type SessionErrorUnknown = { + type: "unknown" + message: string +} + export type EventSessionNextStepFailed = { id: string type: "session.next.step.failed" properties: { timestamp: number sessionID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown } } @@ -2900,10 +2900,7 @@ export type EventSessionNextToolFailed = { timestamp: number sessionID: string callID: string - error: { - type: string - message: string - } + error: SessionErrorUnknown provider: { executed: boolean metadata?: { @@ -2994,7 +2991,7 @@ export type SessionInfo = { model?: { id: string providerID: string - variant?: string + variant: string } time: { created: number @@ -3030,7 +3027,7 @@ export type SessionMessageModelSwitched = { model: { id: string providerID: string - variant?: string + variant: string } } @@ -3124,10 +3121,7 @@ export type SessionMessageToolStateError = { structured: { [key: string]: unknown } - error: { - type: string - message: string - } + error: SessionErrorUnknown } export type SessionMessageAssistantTool = { @@ -3167,7 +3161,7 @@ export type SessionMessageAssistant = { model: { id: string providerID: string - variant?: string + variant: string } content: Array snapshot?: { @@ -3185,10 +3179,7 @@ export type SessionMessageAssistant = { write: number } } - error?: { - type: string - message: string - } + error?: SessionErrorUnknown } export type SessionMessageCompaction = { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index db8889f1a4..fea9dd5a95 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -13998,17 +13998,24 @@ "sessionID": { "type": "string" }, - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "id", "providerID"], + "required": ["timestamp", "sessionID", "model"], "additionalProperties": false } }, @@ -14231,7 +14238,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "snapshot": { @@ -14357,17 +14364,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["timestamp", "sessionID", "error"], @@ -14979,17 +14976,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" }, "provider": { "type": "object", @@ -16267,17 +16254,24 @@ "sessionID": { "type": "string" }, - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "variant": { - "type": "string" + "model": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "required": ["id", "providerID", "variant"], + "additionalProperties": false } }, - "required": ["timestamp", "sessionID", "id", "providerID"], + "required": ["timestamp", "sessionID", "model"], "additionalProperties": false } }, @@ -16496,7 +16490,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "snapshot": { @@ -16580,6 +16574,20 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, + "SessionErrorUnknown": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["unknown"] + }, + "message": { + "type": "string" + } + }, + "required": ["type", "message"], + "additionalProperties": false + }, "EventSessionNextStepFailed": { "type": "object", "properties": { @@ -16600,17 +16608,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["timestamp", "sessionID", "error"], @@ -17113,17 +17111,7 @@ "type": "string" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" }, "provider": { "type": "object", @@ -17376,7 +17364,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "time": { @@ -17472,7 +17460,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false } }, @@ -17731,17 +17719,7 @@ "type": "object" }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["status", "input", "content", "structured", "error"], @@ -17854,7 +17832,7 @@ "type": "string" } }, - "required": ["id", "providerID"], + "required": ["id", "providerID", "variant"], "additionalProperties": false }, "content": { @@ -17921,17 +17899,7 @@ "additionalProperties": false }, "error": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "message"], - "additionalProperties": false + "$ref": "#/components/schemas/SessionErrorUnknown" } }, "required": ["id", "time", "type", "agent", "model", "content"], From 07f1c8c0ac3d08e32c46a73c71786717b5472879 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 5 May 2026 14:26:35 +1000 Subject: [PATCH 165/178] fix(desktop): stabilize Windows titlebar zoom (#25813) --- packages/app/src/components/titlebar.tsx | 21 ++++++++++++++++--- packages/desktop-electron/src/main/ipc.ts | 9 ++++++-- packages/desktop-electron/src/main/windows.ts | 14 ++++++++++--- .../src/renderer/webview-zoom.ts | 15 +++++++++---- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 409fcbeff6..eafea591ae 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -35,6 +35,9 @@ type TauriApi = { const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() +const titlebarHeight = 40 +const minTitlebarZoom = 0.25 +const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. export function Titlebar() { const layout = useLayout() @@ -51,7 +54,14 @@ export function Titlebar() { const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const web = createMemo(() => platform.platform === "web") const zoom = () => platform.webviewZoom?.() ?? 1 - const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined) + const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) + const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1) + const minHeight = () => { + if (mac()) return `${titlebarHeight / zoom()}px` + if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px` + return undefined + } + const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px` const [history, setHistory] = createStore({ stack: [] as string[], @@ -165,12 +175,16 @@ export function Titlebar() { return (
+
- {!tauriApi() &&
} + {!tauriApi() &&
}
+
) } diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 8dbca8eea1..2413613730 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -11,7 +11,7 @@ import type { WslConfig, } from "../preload/types" import { getStore } from "./store" -import { setTitlebar } from "./windows" +import { setTitlebar, updateTitlebar } from "./windows" const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined @@ -183,7 +183,12 @@ export function registerIpcHandlers(deps: Deps) { }) ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor()) - ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor)) + ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => { + event.sender.setZoomFactor(factor) + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return + updateTitlebar(win) + }) ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => { const win = BrowserWindow.fromWebContents(event.sender) if (!win) return diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index 337e1ca0bc..387e793b0e 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -21,6 +21,8 @@ protocol.registerSchemesAsPrivileged([ ]) let backgroundColor: string | undefined +const titlebarThemes = new WeakMap>() +const titlebarHeight = 40 export function setBackgroundColor(color: string) { backgroundColor = color @@ -43,18 +45,23 @@ function tone() { return nativeTheme.shouldUseDarkColors ? "dark" : "light" } -function overlay(theme: Partial = {}) { +function overlay(theme: Partial = {}, zoom = 1) { const mode = theme.mode ?? tone() return { color: "#00000000", symbolColor: mode === "dark" ? "white" : "black", - height: 40, + height: Math.max(titlebarHeight, Math.round(titlebarHeight * zoom)), } } export function setTitlebar(win: BrowserWindow, theme: Partial = {}) { + titlebarThemes.set(win, theme) + updateTitlebar(win) +} + +export function updateTitlebar(win: BrowserWindow) { if (process.platform !== "win32") return - win.setTitleBarOverlay(overlay(theme)) + win.setTitleBarOverlay(overlay(titlebarThemes.get(win), win.webContents.getZoomFactor())) } export function setDockIcon() { @@ -188,6 +195,7 @@ function wireZoom(win: BrowserWindow) { win.webContents.setZoomFactor(1) win.webContents.on("zoom-changed", () => { win.webContents.setZoomFactor(1) + updateTitlebar(win) }) } diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index 6e13266f45..967ff54eb7 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -12,6 +12,7 @@ const OS_NAME = (() => { })() const [webviewZoom, setWebviewZoom] = createSignal(1) +let requestedZoom = 1 const MAX_ZOOM_LEVEL = 10 const MIN_ZOOM_LEVEL = 0.2 @@ -19,8 +20,14 @@ const MIN_ZOOM_LEVEL = 0.2 const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) const applyZoom = (next: number) => { - setWebviewZoom(next) - void window.api.setZoomFactor(next) + requestedZoom = next + void window.api.setZoomFactor(next).then(() => { + if (requestedZoom !== next) return + setWebviewZoom(next) + }).catch(() => { + if (requestedZoom !== next) return + requestedZoom = webviewZoom() + }) } window.addEventListener("keydown", (event) => { @@ -28,12 +35,12 @@ window.addEventListener("keydown", (event) => { if (event.key === "-") { event.preventDefault() - applyZoom(clamp(webviewZoom() - 0.2)) + applyZoom(clamp(requestedZoom - 0.2)) return } if (event.key === "=" || event.key === "+") { event.preventDefault() - applyZoom(clamp(webviewZoom() + 0.2)) + applyZoom(clamp(requestedZoom + 0.2)) return } if (event.key === "0") { From 6f7d63e9ceaacc5debbfcba18bf8391a90e59e8f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 5 May 2026 04:27:38 +0000 Subject: [PATCH 166/178] chore: generate --- packages/app/src/components/titlebar.tsx | 266 +++++++++--------- .../src/renderer/webview-zoom.ts | 17 +- 2 files changed, 143 insertions(+), 140 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index eafea591ae..2917b7adb8 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -185,151 +185,151 @@ export function Titlebar() { class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center" style={{ zoom: counterZoom() }} > -
- -
-
- -
- - -
- -
-
-
- - - - -
-
-
+
+
+
-
-
- - {!tauriApi() &&
} -
- -
+
+
+ + {!tauriApi() &&
} +
+ +
) diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts index 967ff54eb7..cb4b5a4481 100644 --- a/packages/desktop-electron/src/renderer/webview-zoom.ts +++ b/packages/desktop-electron/src/renderer/webview-zoom.ts @@ -21,13 +21,16 @@ const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_Z const applyZoom = (next: number) => { requestedZoom = next - void window.api.setZoomFactor(next).then(() => { - if (requestedZoom !== next) return - setWebviewZoom(next) - }).catch(() => { - if (requestedZoom !== next) return - requestedZoom = webviewZoom() - }) + void window.api + .setZoomFactor(next) + .then(() => { + if (requestedZoom !== next) return + setWebviewZoom(next) + }) + .catch(() => { + if (requestedZoom !== next) return + requestedZoom = webviewZoom() + }) } window.addEventListener("keydown", (event) => { From b4147c8d08b2e14554337536f54c6965006b29ca Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 5 May 2026 13:43:36 +0800 Subject: [PATCH 167/178] refactor(desktop): consolidate desktop-electron into desktop package (#25822) --- .github/workflows/publish.yml | 20 +- bun.lock | 95 +- package.json | 2 +- packages/desktop-electron/.gitignore | 28 - packages/desktop-electron/AGENTS.md | 4 - packages/desktop-electron/README.md | 32 - packages/desktop-electron/package.json | 68 - .../desktop-electron/scripts/copy-bundles.ts | 12 - packages/desktop-electron/scripts/predev.ts | 5 - packages/desktop-electron/scripts/prepare.ts | 9 - packages/desktop-electron/scripts/utils.ts | 77 - packages/desktop-electron/sst-env.d.ts | 10 - packages/desktop-electron/tsconfig.json | 23 - packages/desktop/.gitignore | 4 + packages/desktop/AGENTS.md | 4 +- packages/desktop/README.md | 22 +- .../electron-builder.config.ts | 0 .../electron.vite.config.ts | 0 .../icons/README.md | 0 .../icons/beta/128x128.png | Bin .../icons/beta/128x128@2x.png | Bin .../icons/beta/32x32.png | Bin .../icons/beta/64x64.png | Bin .../icons/beta/Square107x107Logo.png | Bin .../icons/beta/Square142x142Logo.png | Bin .../icons/beta/Square150x150Logo.png | Bin .../icons/beta/Square284x284Logo.png | Bin .../icons/beta/Square30x30Logo.png | Bin .../icons/beta/Square310x310Logo.png | Bin .../icons/beta/Square44x44Logo.png | Bin .../icons/beta/Square71x71Logo.png | Bin .../icons/beta/Square89x89Logo.png | Bin .../icons/beta/StoreLogo.png | Bin .../android/mipmap-anydpi-v26/ic_launcher.xml | 0 .../beta/android/mipmap-hdpi/ic_launcher.png | Bin .../mipmap-hdpi/ic_launcher_foreground.png | Bin .../android/mipmap-hdpi/ic_launcher_round.png | Bin .../beta/android/mipmap-mdpi/ic_launcher.png | Bin .../mipmap-mdpi/ic_launcher_foreground.png | Bin .../android/mipmap-mdpi/ic_launcher_round.png | Bin .../beta/android/mipmap-xhdpi/ic_launcher.png | Bin .../mipmap-xhdpi/ic_launcher_foreground.png | Bin .../mipmap-xhdpi/ic_launcher_round.png | Bin .../android/mipmap-xxhdpi/ic_launcher.png | Bin .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin .../mipmap-xxhdpi/ic_launcher_round.png | Bin .../android/mipmap-xxxhdpi/ic_launcher.png | Bin .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin .../mipmap-xxxhdpi/ic_launcher_round.png | Bin .../android/values/ic_launcher_background.xml | 0 .../icons/beta/dock.png | Bin .../icons/beta/icon.icns | Bin .../icons/beta/icon.ico | Bin .../icons/beta/icon.png | Bin .../icons/beta/ios/AppIcon-20x20@1x.png | Bin .../icons/beta/ios/AppIcon-20x20@2x-1.png | Bin .../icons/beta/ios/AppIcon-20x20@2x.png | Bin .../icons/beta/ios/AppIcon-20x20@3x.png | Bin .../icons/beta/ios/AppIcon-29x29@1x.png | Bin .../icons/beta/ios/AppIcon-29x29@2x-1.png | Bin .../icons/beta/ios/AppIcon-29x29@2x.png | Bin .../icons/beta/ios/AppIcon-29x29@3x.png | Bin .../icons/beta/ios/AppIcon-40x40@1x.png | Bin .../icons/beta/ios/AppIcon-40x40@2x-1.png | Bin .../icons/beta/ios/AppIcon-40x40@2x.png | Bin .../icons/beta/ios/AppIcon-40x40@3x.png | Bin .../icons/beta/ios/AppIcon-512@2x.png | Bin .../icons/beta/ios/AppIcon-60x60@2x.png | Bin .../icons/beta/ios/AppIcon-60x60@3x.png | Bin .../icons/beta/ios/AppIcon-76x76@1x.png | Bin .../icons/beta/ios/AppIcon-76x76@2x.png | Bin .../icons/beta/ios/AppIcon-83.5x83.5@2x.png | Bin .../icons/dev/128x128.png | Bin .../icons/dev/128x128@2x.png | Bin .../icons/dev/32x32.png | Bin .../icons/dev/64x64.png | Bin .../icons/dev/Square107x107Logo.png | Bin .../icons/dev/Square142x142Logo.png | Bin .../icons/dev/Square150x150Logo.png | Bin .../icons/dev/Square284x284Logo.png | Bin .../icons/dev/Square30x30Logo.png | Bin .../icons/dev/Square310x310Logo.png | Bin .../icons/dev/Square44x44Logo.png | Bin .../icons/dev/Square71x71Logo.png | Bin .../icons/dev/Square89x89Logo.png | Bin .../icons/dev/StoreLogo.png | Bin .../android/mipmap-anydpi-v26/ic_launcher.xml | 0 .../dev/android/mipmap-hdpi/ic_launcher.png | Bin .../mipmap-hdpi/ic_launcher_foreground.png | Bin .../android/mipmap-hdpi/ic_launcher_round.png | Bin .../dev/android/mipmap-mdpi/ic_launcher.png | Bin .../mipmap-mdpi/ic_launcher_foreground.png | Bin .../android/mipmap-mdpi/ic_launcher_round.png | Bin .../dev/android/mipmap-xhdpi/ic_launcher.png | Bin .../mipmap-xhdpi/ic_launcher_foreground.png | Bin .../mipmap-xhdpi/ic_launcher_round.png | Bin .../dev/android/mipmap-xxhdpi/ic_launcher.png | Bin .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin .../mipmap-xxhdpi/ic_launcher_round.png | Bin .../android/mipmap-xxxhdpi/ic_launcher.png | Bin .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin .../mipmap-xxxhdpi/ic_launcher_round.png | Bin .../android/values/ic_launcher_background.xml | 0 .../icons/dev/dock.png | Bin .../icons/dev/icon.icns | Bin .../icons/dev/icon.ico | Bin .../icons/dev/icon.png | Bin .../icons/dev/ios/AppIcon-20x20@1x.png | Bin .../icons/dev/ios/AppIcon-20x20@2x-1.png | Bin .../icons/dev/ios/AppIcon-20x20@2x.png | Bin .../icons/dev/ios/AppIcon-20x20@3x.png | Bin .../icons/dev/ios/AppIcon-29x29@1x.png | Bin .../icons/dev/ios/AppIcon-29x29@2x-1.png | Bin .../icons/dev/ios/AppIcon-29x29@2x.png | Bin .../icons/dev/ios/AppIcon-29x29@3x.png | Bin .../icons/dev/ios/AppIcon-40x40@1x.png | Bin .../icons/dev/ios/AppIcon-40x40@2x-1.png | Bin .../icons/dev/ios/AppIcon-40x40@2x.png | Bin .../icons/dev/ios/AppIcon-40x40@3x.png | Bin .../icons/dev/ios/AppIcon-512@2x.png | Bin .../icons/dev/ios/AppIcon-60x60@2x.png | Bin .../icons/dev/ios/AppIcon-60x60@3x.png | Bin .../icons/dev/ios/AppIcon-76x76@1x.png | Bin .../icons/dev/ios/AppIcon-76x76@2x.png | Bin .../icons/dev/ios/AppIcon-83.5x83.5@2x.png | Bin .../icons/prod/128x128.png | Bin .../icons/prod/128x128@2x.png | Bin .../icons/prod/32x32.png | Bin .../icons/prod/64x64.png | Bin .../icons/prod/Square107x107Logo.png | Bin .../icons/prod/Square142x142Logo.png | Bin .../icons/prod/Square150x150Logo.png | Bin .../icons/prod/Square284x284Logo.png | Bin .../icons/prod/Square30x30Logo.png | Bin .../icons/prod/Square310x310Logo.png | Bin .../icons/prod/Square44x44Logo.png | Bin .../icons/prod/Square71x71Logo.png | Bin .../icons/prod/Square89x89Logo.png | Bin .../icons/prod/StoreLogo.png | Bin .../android/mipmap-anydpi-v26/ic_launcher.xml | 0 .../prod/android/mipmap-hdpi/ic_launcher.png | Bin .../mipmap-hdpi/ic_launcher_foreground.png | Bin .../android/mipmap-hdpi/ic_launcher_round.png | Bin .../prod/android/mipmap-mdpi/ic_launcher.png | Bin .../mipmap-mdpi/ic_launcher_foreground.png | Bin .../android/mipmap-mdpi/ic_launcher_round.png | Bin .../prod/android/mipmap-xhdpi/ic_launcher.png | Bin .../mipmap-xhdpi/ic_launcher_foreground.png | Bin .../mipmap-xhdpi/ic_launcher_round.png | Bin .../android/mipmap-xxhdpi/ic_launcher.png | Bin .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin .../mipmap-xxhdpi/ic_launcher_round.png | Bin .../android/mipmap-xxxhdpi/ic_launcher.png | Bin .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin .../mipmap-xxxhdpi/ic_launcher_round.png | Bin .../android/values/ic_launcher_background.xml | 0 .../icons/prod/dock.png | Bin .../icons/prod/icon.icns | Bin .../icons/prod/icon.ico | Bin .../icons/prod/icon.png | Bin .../icons/prod/ios/AppIcon-20x20@1x.png | Bin .../icons/prod/ios/AppIcon-20x20@2x-1.png | Bin .../icons/prod/ios/AppIcon-20x20@2x.png | Bin .../icons/prod/ios/AppIcon-20x20@3x.png | Bin .../icons/prod/ios/AppIcon-29x29@1x.png | Bin .../icons/prod/ios/AppIcon-29x29@2x-1.png | Bin .../icons/prod/ios/AppIcon-29x29@2x.png | Bin .../icons/prod/ios/AppIcon-29x29@3x.png | Bin .../icons/prod/ios/AppIcon-40x40@1x.png | Bin .../icons/prod/ios/AppIcon-40x40@2x-1.png | Bin .../icons/prod/ios/AppIcon-40x40@2x.png | Bin .../icons/prod/ios/AppIcon-40x40@3x.png | Bin .../icons/prod/ios/AppIcon-512@2x.png | Bin .../icons/prod/ios/AppIcon-60x60@2x.png | Bin .../icons/prod/ios/AppIcon-60x60@3x.png | Bin .../icons/prod/ios/AppIcon-76x76@1x.png | Bin .../icons/prod/ios/AppIcon-76x76@2x.png | Bin .../icons/prod/ios/AppIcon-83.5x83.5@2x.png | Bin packages/desktop/index.html | 24 - packages/desktop/package.json | 72 +- .../resources/entitlements.plist | 0 packages/desktop/scripts/copy-bundles.ts | 6 +- .../scripts/copy-icons.ts | 0 .../scripts/finalize-latest-yml.ts | 0 .../scripts/prebuild.ts | 0 packages/desktop/scripts/predev.ts | 14 +- packages/desktop/scripts/prepare.ts | 15 +- packages/desktop/scripts/utils.ts | 28 +- packages/desktop/src-tauri/.gitignore | 9 - packages/desktop/src-tauri/Cargo.lock | 7394 ----------------- packages/desktop/src-tauri/Cargo.toml | 75 - .../desktop/src-tauri/assets/nsis-header.bmp | Bin 25818 -> 0 bytes .../desktop/src-tauri/assets/nsis-sidebar.bmp | Bin 154542 -> 0 bytes packages/desktop/src-tauri/build.rs | 3 - .../src-tauri/capabilities/default.json | 52 - packages/desktop/src-tauri/entitlements.plist | 18 - packages/desktop/src-tauri/icons/README.md | 11 - .../desktop/src-tauri/icons/beta/128x128.png | Bin 10186 -> 0 bytes .../src-tauri/icons/beta/128x128@2x.png | Bin 36252 -> 0 bytes .../desktop/src-tauri/icons/beta/32x32.png | Bin 1309 -> 0 bytes .../desktop/src-tauri/icons/beta/64x64.png | Bin 3587 -> 0 bytes .../icons/beta/Square107x107Logo.png | Bin 7562 -> 0 bytes .../icons/beta/Square142x142Logo.png | Bin 12279 -> 0 bytes .../icons/beta/Square150x150Logo.png | Bin 13445 -> 0 bytes .../icons/beta/Square284x284Logo.png | Bin 45201 -> 0 bytes .../src-tauri/icons/beta/Square30x30Logo.png | Bin 1281 -> 0 bytes .../icons/beta/Square310x310Logo.png | Bin 54725 -> 0 bytes .../src-tauri/icons/beta/Square44x44Logo.png | Bin 2167 -> 0 bytes .../src-tauri/icons/beta/Square71x71Logo.png | Bin 4121 -> 0 bytes .../src-tauri/icons/beta/Square89x89Logo.png | Bin 5782 -> 0 bytes .../src-tauri/icons/beta/StoreLogo.png | Bin 2559 -> 0 bytes .../android/mipmap-anydpi-v26/ic_launcher.xml | 5 - .../beta/android/mipmap-hdpi/ic_launcher.png | Bin 2077 -> 0 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 15269 -> 0 bytes .../android/mipmap-hdpi/ic_launcher_round.png | Bin 1887 -> 0 bytes .../beta/android/mipmap-mdpi/ic_launcher.png | Bin 2083 -> 0 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 7845 -> 0 bytes .../android/mipmap-mdpi/ic_launcher_round.png | Bin 1792 -> 0 bytes .../beta/android/mipmap-xhdpi/ic_launcher.png | Bin 5778 -> 0 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 25523 -> 0 bytes .../mipmap-xhdpi/ic_launcher_round.png | Bin 5026 -> 0 bytes .../android/mipmap-xxhdpi/ic_launcher.png | Bin 10758 -> 0 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 60763 -> 0 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 9312 -> 0 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 17122 -> 0 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 116520 -> 0 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 14941 -> 0 bytes .../android/values/ic_launcher_background.xml | 4 - .../desktop/src-tauri/icons/beta/icon.icns | Bin 882048 -> 0 bytes .../desktop/src-tauri/icons/beta/icon.ico | Bin 49612 -> 0 bytes .../desktop/src-tauri/icons/beta/icon.png | Bin 172485 -> 0 bytes .../icons/beta/ios/AppIcon-20x20@1x.png | Bin 687 -> 0 bytes .../icons/beta/ios/AppIcon-20x20@2x-1.png | Bin 1660 -> 0 bytes .../icons/beta/ios/AppIcon-20x20@2x.png | Bin 1660 -> 0 bytes .../icons/beta/ios/AppIcon-20x20@3x.png | Bin 2950 -> 0 bytes .../icons/beta/ios/AppIcon-29x29@1x.png | Bin 1072 -> 0 bytes .../icons/beta/ios/AppIcon-29x29@2x-1.png | Bin 2834 -> 0 bytes .../icons/beta/ios/AppIcon-29x29@2x.png | Bin 2834 -> 0 bytes .../icons/beta/ios/AppIcon-29x29@3x.png | Bin 5048 -> 0 bytes .../icons/beta/ios/AppIcon-40x40@1x.png | Bin 1660 -> 0 bytes .../icons/beta/ios/AppIcon-40x40@2x-1.png | Bin 4396 -> 0 bytes .../icons/beta/ios/AppIcon-40x40@2x.png | Bin 4396 -> 0 bytes .../icons/beta/ios/AppIcon-40x40@3x.png | Bin 8452 -> 0 bytes .../icons/beta/ios/AppIcon-512@2x.png | Bin 596205 -> 0 bytes .../icons/beta/ios/AppIcon-60x60@2x.png | Bin 8452 -> 0 bytes .../icons/beta/ios/AppIcon-60x60@3x.png | Bin 16916 -> 0 bytes .../icons/beta/ios/AppIcon-76x76@1x.png | Bin 4193 -> 0 bytes .../icons/beta/ios/AppIcon-76x76@2x.png | Bin 12523 -> 0 bytes .../icons/beta/ios/AppIcon-83.5x83.5@2x.png | Bin 14760 -> 0 bytes .../desktop/src-tauri/icons/dev/128x128.png | Bin 16568 -> 0 bytes .../src-tauri/icons/dev/128x128@2x.png | Bin 59884 -> 0 bytes .../desktop/src-tauri/icons/dev/32x32.png | Bin 1973 -> 0 bytes .../desktop/src-tauri/icons/dev/64x64.png | Bin 5469 -> 0 bytes .../src-tauri/icons/dev/Square107x107Logo.png | Bin 12116 -> 0 bytes .../src-tauri/icons/dev/Square142x142Logo.png | Bin 19936 -> 0 bytes .../src-tauri/icons/dev/Square150x150Logo.png | Bin 21988 -> 0 bytes .../src-tauri/icons/dev/Square284x284Logo.png | Bin 74022 -> 0 bytes .../src-tauri/icons/dev/Square30x30Logo.png | Bin 1786 -> 0 bytes .../src-tauri/icons/dev/Square310x310Logo.png | Bin 89075 -> 0 bytes .../src-tauri/icons/dev/Square44x44Logo.png | Bin 3211 -> 0 bytes .../src-tauri/icons/dev/Square71x71Logo.png | Bin 6370 -> 0 bytes .../src-tauri/icons/dev/Square89x89Logo.png | Bin 9316 -> 0 bytes .../desktop/src-tauri/icons/dev/StoreLogo.png | Bin 3862 -> 0 bytes .../android/mipmap-anydpi-v26/ic_launcher.xml | 5 - .../dev/android/mipmap-hdpi/ic_launcher.png | Bin 3076 -> 0 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 24987 -> 0 bytes .../android/mipmap-hdpi/ic_launcher_round.png | Bin 2853 -> 0 bytes .../dev/android/mipmap-mdpi/ic_launcher.png | Bin 3016 -> 0 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 12682 -> 0 bytes .../android/mipmap-mdpi/ic_launcher_round.png | Bin 2702 -> 0 bytes .../dev/android/mipmap-xhdpi/ic_launcher.png | Bin 8701 -> 0 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 42285 -> 0 bytes .../mipmap-xhdpi/ic_launcher_round.png | Bin 7640 -> 0 bytes .../dev/android/mipmap-xxhdpi/ic_launcher.png | Bin 16970 -> 0 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 97586 -> 0 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 14939 -> 0 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 27316 -> 0 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 180625 -> 0 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 24066 -> 0 bytes .../android/values/ic_launcher_background.xml | 4 - .../desktop/src-tauri/icons/dev/icon.icns | Bin 1187792 -> 0 bytes packages/desktop/src-tauri/icons/dev/icon.ico | Bin 73182 -> 0 bytes packages/desktop/src-tauri/icons/dev/icon.png | Bin 264014 -> 0 bytes .../icons/dev/ios/AppIcon-20x20@1x.png | Bin 955 -> 0 bytes .../icons/dev/ios/AppIcon-20x20@2x-1.png | Bin 2695 -> 0 bytes .../icons/dev/ios/AppIcon-20x20@2x.png | Bin 2695 -> 0 bytes .../icons/dev/ios/AppIcon-20x20@3x.png | Bin 4932 -> 0 bytes .../icons/dev/ios/AppIcon-29x29@1x.png | Bin 1640 -> 0 bytes .../icons/dev/ios/AppIcon-29x29@2x-1.png | Bin 4684 -> 0 bytes .../icons/dev/ios/AppIcon-29x29@2x.png | Bin 4684 -> 0 bytes .../icons/dev/ios/AppIcon-29x29@3x.png | Bin 8781 -> 0 bytes .../icons/dev/ios/AppIcon-40x40@1x.png | Bin 2695 -> 0 bytes .../icons/dev/ios/AppIcon-40x40@2x-1.png | Bin 7529 -> 0 bytes .../icons/dev/ios/AppIcon-40x40@2x.png | Bin 7529 -> 0 bytes .../icons/dev/ios/AppIcon-40x40@3x.png | Bin 14557 -> 0 bytes .../icons/dev/ios/AppIcon-512@2x.png | Bin 980713 -> 0 bytes .../icons/dev/ios/AppIcon-60x60@2x.png | Bin 14557 -> 0 bytes .../icons/dev/ios/AppIcon-60x60@3x.png | Bin 29995 -> 0 bytes .../icons/dev/ios/AppIcon-76x76@1x.png | Bin 7093 -> 0 bytes .../icons/dev/ios/AppIcon-76x76@2x.png | Bin 22066 -> 0 bytes .../icons/dev/ios/AppIcon-83.5x83.5@2x.png | Bin 25898 -> 0 bytes .../desktop/src-tauri/icons/prod/128x128.png | Bin 9013 -> 0 bytes .../src-tauri/icons/prod/128x128@2x.png | Bin 36840 -> 0 bytes .../desktop/src-tauri/icons/prod/32x32.png | Bin 1255 -> 0 bytes .../desktop/src-tauri/icons/prod/64x64.png | Bin 2971 -> 0 bytes .../icons/prod/Square107x107Logo.png | Bin 6441 -> 0 bytes .../icons/prod/Square142x142Logo.png | Bin 10850 -> 0 bytes .../icons/prod/Square150x150Logo.png | Bin 12036 -> 0 bytes .../icons/prod/Square284x284Logo.png | Bin 47137 -> 0 bytes .../src-tauri/icons/prod/Square30x30Logo.png | Bin 1109 -> 0 bytes .../icons/prod/Square310x310Logo.png | Bin 58165 -> 0 bytes .../src-tauri/icons/prod/Square44x44Logo.png | Bin 1827 -> 0 bytes .../src-tauri/icons/prod/Square71x71Logo.png | Bin 3405 -> 0 bytes .../src-tauri/icons/prod/Square89x89Logo.png | Bin 4760 -> 0 bytes .../src-tauri/icons/prod/StoreLogo.png | Bin 2186 -> 0 bytes .../android/mipmap-anydpi-v26/ic_launcher.xml | 5 - .../prod/android/mipmap-hdpi/ic_launcher.png | Bin 1886 -> 0 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 13918 -> 0 bytes .../android/mipmap-hdpi/ic_launcher_round.png | Bin 1811 -> 0 bytes .../prod/android/mipmap-mdpi/ic_launcher.png | Bin 1873 -> 0 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 6540 -> 0 bytes .../android/mipmap-mdpi/ic_launcher_round.png | Bin 1751 -> 0 bytes .../prod/android/mipmap-xhdpi/ic_launcher.png | Bin 4726 -> 0 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 25393 -> 0 bytes .../mipmap-xhdpi/ic_launcher_round.png | Bin 4101 -> 0 bytes .../android/mipmap-xxhdpi/ic_launcher.png | Bin 9156 -> 0 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 64829 -> 0 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 8270 -> 0 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 15359 -> 0 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 127895 -> 0 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 14064 -> 0 bytes .../android/values/ic_launcher_background.xml | 4 - .../desktop/src-tauri/icons/prod/icon.icns | Bin 1010901 -> 0 bytes .../desktop/src-tauri/icons/prod/icon.ico | Bin 47600 -> 0 bytes .../desktop/src-tauri/icons/prod/icon.png | Bin 190179 -> 0 bytes .../icons/prod/ios/AppIcon-20x20@1x.png | Bin 728 -> 0 bytes .../icons/prod/ios/AppIcon-20x20@2x-1.png | Bin 1607 -> 0 bytes .../icons/prod/ios/AppIcon-20x20@2x.png | Bin 1607 -> 0 bytes .../icons/prod/ios/AppIcon-20x20@3x.png | Bin 2648 -> 0 bytes .../icons/prod/ios/AppIcon-29x29@1x.png | Bin 1094 -> 0 bytes .../icons/prod/ios/AppIcon-29x29@2x-1.png | Bin 2542 -> 0 bytes .../icons/prod/ios/AppIcon-29x29@2x.png | Bin 2542 -> 0 bytes .../icons/prod/ios/AppIcon-29x29@3x.png | Bin 4709 -> 0 bytes .../icons/prod/ios/AppIcon-40x40@1x.png | Bin 1607 -> 0 bytes .../icons/prod/ios/AppIcon-40x40@2x-1.png | Bin 4058 -> 0 bytes .../icons/prod/ios/AppIcon-40x40@2x.png | Bin 4058 -> 0 bytes .../icons/prod/ios/AppIcon-40x40@3x.png | Bin 7828 -> 0 bytes .../icons/prod/ios/AppIcon-512@2x.png | Bin 681769 -> 0 bytes .../icons/prod/ios/AppIcon-60x60@2x.png | Bin 7828 -> 0 bytes .../icons/prod/ios/AppIcon-60x60@3x.png | Bin 17106 -> 0 bytes .../icons/prod/ios/AppIcon-76x76@1x.png | Bin 3730 -> 0 bytes .../icons/prod/ios/AppIcon-76x76@2x.png | Bin 12166 -> 0 bytes .../icons/prod/ios/AppIcon-83.5x83.5@2x.png | Bin 14705 -> 0 bytes .../src-tauri/release/appstream.metainfo.xml | 130 - packages/desktop/src-tauri/src/cli.rs | 742 -- packages/desktop/src-tauri/src/constants.rs | 10 - packages/desktop/src-tauri/src/lib.rs | 601 -- .../desktop/src-tauri/src/linux_display.rs | 53 - .../desktop/src-tauri/src/linux_windowing.rs | 475 -- packages/desktop/src-tauri/src/logging.rs | 76 - packages/desktop/src-tauri/src/main.rs | 78 - packages/desktop/src-tauri/src/markdown.rs | 63 - packages/desktop/src-tauri/src/os/mod.rs | 2 - packages/desktop/src-tauri/src/os/windows.rs | 463 -- packages/desktop/src-tauri/src/server.rs | 170 - .../src-tauri/src/window_customizer.rs | 46 - packages/desktop/src-tauri/src/windows.rs | 174 - .../desktop/src-tauri/tauri.beta.conf.json | 37 - packages/desktop/src-tauri/tauri.conf.json | 67 - .../desktop/src-tauri/tauri.prod.conf.json | 42 - packages/desktop/src/bindings.ts | 67 - packages/desktop/src/cli.ts | 43 - packages/desktop/src/entry.tsx | 5 - packages/desktop/src/env.d.ts | 9 - packages/desktop/src/i18n/ar.ts | 59 - packages/desktop/src/i18n/br.ts | 61 - packages/desktop/src/i18n/bs.ts | 62 - packages/desktop/src/i18n/da.ts | 61 - packages/desktop/src/i18n/de.ts | 62 - packages/desktop/src/i18n/en.ts | 61 - packages/desktop/src/i18n/es.ts | 61 - packages/desktop/src/i18n/fr.ts | 62 - packages/desktop/src/i18n/index.ts | 192 - packages/desktop/src/i18n/ja.ts | 62 - packages/desktop/src/i18n/ko.ts | 60 - packages/desktop/src/i18n/no.ts | 61 - packages/desktop/src/i18n/pl.ts | 62 - packages/desktop/src/i18n/ru.ts | 61 - packages/desktop/src/i18n/zh.ts | 59 - packages/desktop/src/i18n/zht.ts | 59 - packages/desktop/src/index.tsx | 505 -- packages/desktop/src/loading.tsx | 90 - .../src/main/apps.ts | 0 .../src/main/constants.ts | 0 .../src/main/env.d.ts | 0 .../src/main/index.ts | 0 .../src/main/ipc.ts | 0 .../src/main/logging.ts | 0 .../src/main/markdown.ts | 0 .../src/main/menu.ts | 0 .../src/main/migrate.ts | 0 .../src/main/server.ts | 0 .../src/main/shell-env.test.ts | 0 .../src/main/shell-env.ts | 0 .../src/main/store.ts | 2 +- .../src/main/windows.ts | 0 packages/desktop/src/menu.ts | 190 - .../src/preload/index.ts | 0 .../src/preload/types.ts | 0 .../src/renderer/cli.ts | 0 .../src/renderer/env.d.ts | 0 .../src/renderer/html.test.ts | 0 .../src/renderer/i18n/ar.ts | 0 .../src/renderer/i18n/br.ts | 0 .../src/renderer/i18n/bs.ts | 0 .../src/renderer/i18n/da.ts | 0 .../src/renderer/i18n/de.ts | 0 .../src/renderer/i18n/en.ts | 0 .../src/renderer/i18n/es.ts | 0 .../src/renderer/i18n/fr.ts | 0 .../src/renderer/i18n/index.ts | 0 .../src/renderer/i18n/ja.ts | 0 .../src/renderer/i18n/ko.ts | 0 .../src/renderer/i18n/no.ts | 0 .../src/renderer/i18n/pl.ts | 0 .../src/renderer/i18n/ru.ts | 0 .../src/renderer/i18n/zh.ts | 0 .../src/renderer/i18n/zht.ts | 0 .../src/renderer/index.html | 0 .../src/renderer/index.tsx | 4 +- .../src/renderer/loading.html | 0 .../src/renderer/loading.tsx | 0 .../src/renderer/styles.css | 0 .../src/renderer/updater.ts | 0 .../src/renderer/webview-zoom.ts | 0 packages/desktop/src/styles.css | 7 - packages/desktop/src/updater.ts | 51 - packages/desktop/src/webview-zoom.ts | 37 - packages/desktop/tsconfig.json | 5 +- packages/desktop/vite.config.ts | 38 - script/publish.ts | 2 +- 441 files changed, 113 insertions(+), 13439 deletions(-) delete mode 100644 packages/desktop-electron/.gitignore delete mode 100644 packages/desktop-electron/AGENTS.md delete mode 100644 packages/desktop-electron/README.md delete mode 100644 packages/desktop-electron/package.json delete mode 100644 packages/desktop-electron/scripts/copy-bundles.ts delete mode 100644 packages/desktop-electron/scripts/predev.ts delete mode 100755 packages/desktop-electron/scripts/prepare.ts delete mode 100644 packages/desktop-electron/scripts/utils.ts delete mode 100644 packages/desktop-electron/sst-env.d.ts delete mode 100644 packages/desktop-electron/tsconfig.json rename packages/{desktop-electron => desktop}/electron-builder.config.ts (100%) rename packages/{desktop-electron => desktop}/electron.vite.config.ts (100%) rename packages/{desktop-electron => desktop}/icons/README.md (100%) rename packages/{desktop-electron => desktop}/icons/beta/128x128.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/128x128@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/32x32.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/64x64.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/Square107x107Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/Square142x142Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/Square150x150Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/Square284x284Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/Square30x30Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/Square310x310Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/Square44x44Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/Square71x71Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/Square89x89Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/StoreLogo.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-hdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-hdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-mdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-mdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-xhdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-xxhdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/android/values/ic_launcher_background.xml (100%) rename packages/{desktop-electron => desktop}/icons/beta/dock.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/icon.icns (100%) rename packages/{desktop-electron => desktop}/icons/beta/icon.ico (100%) rename packages/{desktop-electron => desktop}/icons/beta/icon.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-20x20@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-20x20@2x-1.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-20x20@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-20x20@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-29x29@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-29x29@2x-1.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-29x29@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-29x29@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-40x40@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-40x40@2x-1.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-40x40@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-40x40@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-512@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-60x60@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-60x60@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-76x76@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-76x76@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/beta/ios/AppIcon-83.5x83.5@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/128x128.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/128x128@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/32x32.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/64x64.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/Square107x107Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/Square142x142Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/Square150x150Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/Square284x284Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/Square30x30Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/Square310x310Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/Square44x44Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/Square71x71Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/Square89x89Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/StoreLogo.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-hdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-hdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-mdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-mdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-xhdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-xxhdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/android/values/ic_launcher_background.xml (100%) rename packages/{desktop-electron => desktop}/icons/dev/dock.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/icon.icns (100%) rename packages/{desktop-electron => desktop}/icons/dev/icon.ico (100%) rename packages/{desktop-electron => desktop}/icons/dev/icon.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-20x20@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-20x20@2x-1.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-20x20@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-20x20@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-29x29@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-29x29@2x-1.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-29x29@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-29x29@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-40x40@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-40x40@2x-1.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-40x40@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-40x40@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-512@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-60x60@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-60x60@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-76x76@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-76x76@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/dev/ios/AppIcon-83.5x83.5@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/128x128.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/128x128@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/32x32.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/64x64.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/Square107x107Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/Square142x142Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/Square150x150Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/Square284x284Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/Square30x30Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/Square310x310Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/Square44x44Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/Square71x71Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/Square89x89Logo.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/StoreLogo.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-hdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-hdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-mdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-mdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-xhdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-xxhdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/android/values/ic_launcher_background.xml (100%) rename packages/{desktop-electron => desktop}/icons/prod/dock.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/icon.icns (100%) rename packages/{desktop-electron => desktop}/icons/prod/icon.ico (100%) rename packages/{desktop-electron => desktop}/icons/prod/icon.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-20x20@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-20x20@2x-1.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-20x20@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-20x20@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-29x29@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-29x29@2x-1.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-29x29@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-29x29@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-40x40@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-40x40@2x-1.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-40x40@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-40x40@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-512@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-60x60@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-60x60@3x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-76x76@1x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-76x76@2x.png (100%) rename packages/{desktop-electron => desktop}/icons/prod/ios/AppIcon-83.5x83.5@2x.png (100%) delete mode 100644 packages/desktop/index.html rename packages/{desktop-electron => desktop}/resources/entitlements.plist (100%) rename packages/{desktop-electron => desktop}/scripts/copy-icons.ts (100%) rename packages/{desktop-electron => desktop}/scripts/finalize-latest-yml.ts (100%) rename packages/{desktop-electron => desktop}/scripts/prebuild.ts (100%) delete mode 100644 packages/desktop/src-tauri/.gitignore delete mode 100644 packages/desktop/src-tauri/Cargo.lock delete mode 100644 packages/desktop/src-tauri/Cargo.toml delete mode 100644 packages/desktop/src-tauri/assets/nsis-header.bmp delete mode 100644 packages/desktop/src-tauri/assets/nsis-sidebar.bmp delete mode 100644 packages/desktop/src-tauri/build.rs delete mode 100644 packages/desktop/src-tauri/capabilities/default.json delete mode 100644 packages/desktop/src-tauri/entitlements.plist delete mode 100644 packages/desktop/src-tauri/icons/README.md delete mode 100644 packages/desktop/src-tauri/icons/beta/128x128.png delete mode 100644 packages/desktop/src-tauri/icons/beta/128x128@2x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/32x32.png delete mode 100644 packages/desktop/src-tauri/icons/beta/64x64.png delete mode 100644 packages/desktop/src-tauri/icons/beta/Square107x107Logo.png delete mode 100644 packages/desktop/src-tauri/icons/beta/Square142x142Logo.png delete mode 100644 packages/desktop/src-tauri/icons/beta/Square150x150Logo.png delete mode 100644 packages/desktop/src-tauri/icons/beta/Square284x284Logo.png delete mode 100644 packages/desktop/src-tauri/icons/beta/Square30x30Logo.png delete mode 100644 packages/desktop/src-tauri/icons/beta/Square310x310Logo.png delete mode 100644 packages/desktop/src-tauri/icons/beta/Square44x44Logo.png delete mode 100644 packages/desktop/src-tauri/icons/beta/Square71x71Logo.png delete mode 100644 packages/desktop/src-tauri/icons/beta/Square89x89Logo.png delete mode 100644 packages/desktop/src-tauri/icons/beta/StoreLogo.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-hdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-hdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-mdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-mdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-xhdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-xxhdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/beta/android/values/ic_launcher_background.xml delete mode 100644 packages/desktop/src-tauri/icons/beta/icon.icns delete mode 100644 packages/desktop/src-tauri/icons/beta/icon.ico delete mode 100644 packages/desktop/src-tauri/icons/beta/icon.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-20x20@1x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-20x20@2x-1.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-20x20@2x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-20x20@3x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-29x29@1x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-29x29@2x-1.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-29x29@2x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-29x29@3x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-40x40@1x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-40x40@2x-1.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-40x40@2x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-40x40@3x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-512@2x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-60x60@2x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-60x60@3x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-76x76@1x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-76x76@2x.png delete mode 100644 packages/desktop/src-tauri/icons/beta/ios/AppIcon-83.5x83.5@2x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/128x128.png delete mode 100644 packages/desktop/src-tauri/icons/dev/128x128@2x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/32x32.png delete mode 100644 packages/desktop/src-tauri/icons/dev/64x64.png delete mode 100644 packages/desktop/src-tauri/icons/dev/Square107x107Logo.png delete mode 100644 packages/desktop/src-tauri/icons/dev/Square142x142Logo.png delete mode 100644 packages/desktop/src-tauri/icons/dev/Square150x150Logo.png delete mode 100644 packages/desktop/src-tauri/icons/dev/Square284x284Logo.png delete mode 100644 packages/desktop/src-tauri/icons/dev/Square30x30Logo.png delete mode 100644 packages/desktop/src-tauri/icons/dev/Square310x310Logo.png delete mode 100644 packages/desktop/src-tauri/icons/dev/Square44x44Logo.png delete mode 100644 packages/desktop/src-tauri/icons/dev/Square71x71Logo.png delete mode 100644 packages/desktop/src-tauri/icons/dev/Square89x89Logo.png delete mode 100644 packages/desktop/src-tauri/icons/dev/StoreLogo.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-hdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-hdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-mdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-mdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-xhdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-xxhdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/dev/android/values/ic_launcher_background.xml delete mode 100644 packages/desktop/src-tauri/icons/dev/icon.icns delete mode 100644 packages/desktop/src-tauri/icons/dev/icon.ico delete mode 100644 packages/desktop/src-tauri/icons/dev/icon.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-20x20@1x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-20x20@2x-1.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-20x20@2x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-20x20@3x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-29x29@1x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-29x29@2x-1.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-29x29@2x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-29x29@3x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-40x40@1x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-40x40@2x-1.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-40x40@2x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-40x40@3x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-512@2x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-60x60@2x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-60x60@3x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-76x76@1x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-76x76@2x.png delete mode 100644 packages/desktop/src-tauri/icons/dev/ios/AppIcon-83.5x83.5@2x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/128x128.png delete mode 100644 packages/desktop/src-tauri/icons/prod/128x128@2x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/32x32.png delete mode 100644 packages/desktop/src-tauri/icons/prod/64x64.png delete mode 100644 packages/desktop/src-tauri/icons/prod/Square107x107Logo.png delete mode 100644 packages/desktop/src-tauri/icons/prod/Square142x142Logo.png delete mode 100644 packages/desktop/src-tauri/icons/prod/Square150x150Logo.png delete mode 100644 packages/desktop/src-tauri/icons/prod/Square284x284Logo.png delete mode 100644 packages/desktop/src-tauri/icons/prod/Square30x30Logo.png delete mode 100644 packages/desktop/src-tauri/icons/prod/Square310x310Logo.png delete mode 100644 packages/desktop/src-tauri/icons/prod/Square44x44Logo.png delete mode 100644 packages/desktop/src-tauri/icons/prod/Square71x71Logo.png delete mode 100644 packages/desktop/src-tauri/icons/prod/Square89x89Logo.png delete mode 100644 packages/desktop/src-tauri/icons/prod/StoreLogo.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-hdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-hdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-mdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-mdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-xhdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-xxhdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png delete mode 100644 packages/desktop/src-tauri/icons/prod/android/values/ic_launcher_background.xml delete mode 100644 packages/desktop/src-tauri/icons/prod/icon.icns delete mode 100644 packages/desktop/src-tauri/icons/prod/icon.ico delete mode 100644 packages/desktop/src-tauri/icons/prod/icon.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-20x20@1x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-20x20@2x-1.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-20x20@2x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-20x20@3x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-29x29@1x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-29x29@2x-1.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-29x29@2x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-29x29@3x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-40x40@1x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-40x40@2x-1.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-40x40@2x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-40x40@3x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-512@2x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-60x60@2x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-60x60@3x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-76x76@1x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-76x76@2x.png delete mode 100644 packages/desktop/src-tauri/icons/prod/ios/AppIcon-83.5x83.5@2x.png delete mode 100644 packages/desktop/src-tauri/release/appstream.metainfo.xml delete mode 100644 packages/desktop/src-tauri/src/cli.rs delete mode 100644 packages/desktop/src-tauri/src/constants.rs delete mode 100644 packages/desktop/src-tauri/src/lib.rs delete mode 100644 packages/desktop/src-tauri/src/linux_display.rs delete mode 100644 packages/desktop/src-tauri/src/linux_windowing.rs delete mode 100644 packages/desktop/src-tauri/src/logging.rs delete mode 100644 packages/desktop/src-tauri/src/main.rs delete mode 100644 packages/desktop/src-tauri/src/markdown.rs delete mode 100644 packages/desktop/src-tauri/src/os/mod.rs delete mode 100644 packages/desktop/src-tauri/src/os/windows.rs delete mode 100644 packages/desktop/src-tauri/src/server.rs delete mode 100644 packages/desktop/src-tauri/src/window_customizer.rs delete mode 100644 packages/desktop/src-tauri/src/windows.rs delete mode 100644 packages/desktop/src-tauri/tauri.beta.conf.json delete mode 100644 packages/desktop/src-tauri/tauri.conf.json delete mode 100644 packages/desktop/src-tauri/tauri.prod.conf.json delete mode 100644 packages/desktop/src/bindings.ts delete mode 100644 packages/desktop/src/cli.ts delete mode 100644 packages/desktop/src/entry.tsx delete mode 100644 packages/desktop/src/env.d.ts delete mode 100644 packages/desktop/src/i18n/ar.ts delete mode 100644 packages/desktop/src/i18n/br.ts delete mode 100644 packages/desktop/src/i18n/bs.ts delete mode 100644 packages/desktop/src/i18n/da.ts delete mode 100644 packages/desktop/src/i18n/de.ts delete mode 100644 packages/desktop/src/i18n/en.ts delete mode 100644 packages/desktop/src/i18n/es.ts delete mode 100644 packages/desktop/src/i18n/fr.ts delete mode 100644 packages/desktop/src/i18n/index.ts delete mode 100644 packages/desktop/src/i18n/ja.ts delete mode 100644 packages/desktop/src/i18n/ko.ts delete mode 100644 packages/desktop/src/i18n/no.ts delete mode 100644 packages/desktop/src/i18n/pl.ts delete mode 100644 packages/desktop/src/i18n/ru.ts delete mode 100644 packages/desktop/src/i18n/zh.ts delete mode 100644 packages/desktop/src/i18n/zht.ts delete mode 100644 packages/desktop/src/index.tsx delete mode 100644 packages/desktop/src/loading.tsx rename packages/{desktop-electron => desktop}/src/main/apps.ts (100%) rename packages/{desktop-electron => desktop}/src/main/constants.ts (100%) rename packages/{desktop-electron => desktop}/src/main/env.d.ts (100%) rename packages/{desktop-electron => desktop}/src/main/index.ts (100%) rename packages/{desktop-electron => desktop}/src/main/ipc.ts (100%) rename packages/{desktop-electron => desktop}/src/main/logging.ts (100%) rename packages/{desktop-electron => desktop}/src/main/markdown.ts (100%) rename packages/{desktop-electron => desktop}/src/main/menu.ts (100%) rename packages/{desktop-electron => desktop}/src/main/migrate.ts (100%) rename packages/{desktop-electron => desktop}/src/main/server.ts (100%) rename packages/{desktop-electron => desktop}/src/main/shell-env.test.ts (100%) rename packages/{desktop-electron => desktop}/src/main/shell-env.ts (100%) rename packages/{desktop-electron => desktop}/src/main/store.ts (81%) rename packages/{desktop-electron => desktop}/src/main/windows.ts (100%) delete mode 100644 packages/desktop/src/menu.ts rename packages/{desktop-electron => desktop}/src/preload/index.ts (100%) rename packages/{desktop-electron => desktop}/src/preload/types.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/cli.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/env.d.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/html.test.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/ar.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/br.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/bs.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/da.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/de.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/en.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/es.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/fr.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/index.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/ja.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/ko.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/no.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/pl.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/ru.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/zh.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/i18n/zht.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/index.html (100%) rename packages/{desktop-electron => desktop}/src/renderer/index.tsx (98%) rename packages/{desktop-electron => desktop}/src/renderer/loading.html (100%) rename packages/{desktop-electron => desktop}/src/renderer/loading.tsx (100%) rename packages/{desktop-electron => desktop}/src/renderer/styles.css (100%) rename packages/{desktop-electron => desktop}/src/renderer/updater.ts (100%) rename packages/{desktop-electron => desktop}/src/renderer/webview-zoom.ts (100%) delete mode 100644 packages/desktop/src/styles.css delete mode 100644 packages/desktop/src/updater.ts delete mode 100644 packages/desktop/src/webview-zoom.ts delete mode 100644 packages/desktop/vite.config.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4614226a8a..5f7ee96b90 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -304,7 +304,7 @@ jobs: - name: Prepare run: bun ./scripts/prepare.ts - working-directory: packages/desktop-electron + working-directory: packages/desktop env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} @@ -315,7 +315,7 @@ jobs: - name: Build run: bun run build - working-directory: packages/desktop-electron + working-directory: packages/desktop env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -329,7 +329,7 @@ jobs: - name: Package and publish if: needs.version.outputs.release run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts - working-directory: packages/desktop-electron + working-directory: packages/desktop timeout-minutes: 60 env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} @@ -343,14 +343,14 @@ jobs: - name: Package (no publish) if: ${{ !needs.version.outputs.release }} run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts - working-directory: packages/desktop-electron + working-directory: packages/desktop timeout-minutes: 60 env: OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - name: Create and upload macOS .app.tar.gz if: runner.os == 'macOS' && needs.version.outputs.release - working-directory: packages/desktop-electron/dist + working-directory: packages/desktop/dist env: GH_TOKEN: ${{ steps.committer.outputs.token }} run: | @@ -377,9 +377,9 @@ jobs: shell: pwsh run: | $files = @() - $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName - $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName - $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName + $files += Get-ChildItem "${{ github.workspace }}\packages\desktop\dist\*.exe" | Select-Object -ExpandProperty FullName + $files += Get-ChildItem "${{ github.workspace }}\packages\desktop\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName + $files += Get-ChildItem "${{ github.workspace }}\packages\desktop\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName foreach ($file in $files | Select-Object -Unique) { $sig = Get-AuthenticodeSignature $file @@ -391,13 +391,13 @@ jobs: - uses: actions/upload-artifact@v4 with: name: opencode-desktop-${{ matrix.settings.target }} - path: packages/desktop-electron/dist/* + path: packages/desktop/dist/* - uses: actions/upload-artifact@v4 if: needs.version.outputs.release with: name: latest-yml-${{ matrix.settings.target }} - path: packages/desktop-electron/dist/latest*.yml + path: packages/desktop/dist/latest*.yml publish: needs: diff --git a/bun.lock b/bun.lock index 07415dd79f..5067655ae9 100644 --- a/bun.lock +++ b/bun.lock @@ -229,41 +229,6 @@ "packages/desktop": { "name": "@opencode-ai/desktop", "version": "1.14.35", - "dependencies": { - "@opencode-ai/app": "workspace:*", - "@opencode-ai/ui": "workspace:*", - "@sentry/solid": "catalog:", - "@solid-primitives/i18n": "2.2.1", - "@solid-primitives/storage": "catalog:", - "@solidjs/meta": "catalog:", - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-clipboard-manager": "~2", - "@tauri-apps/plugin-deep-link": "~2", - "@tauri-apps/plugin-dialog": "~2", - "@tauri-apps/plugin-http": "~2", - "@tauri-apps/plugin-notification": "~2", - "@tauri-apps/plugin-opener": "^2", - "@tauri-apps/plugin-os": "~2", - "@tauri-apps/plugin-process": "~2", - "@tauri-apps/plugin-shell": "~2", - "@tauri-apps/plugin-store": "~2", - "@tauri-apps/plugin-updater": "~2", - "@tauri-apps/plugin-window-state": "~2", - "solid-js": "catalog:", - }, - "devDependencies": { - "@actions/artifact": "4.0.0", - "@sentry/vite-plugin": "catalog:", - "@tauri-apps/cli": "^2", - "@types/bun": "catalog:", - "@typescript/native-preview": "catalog:", - "typescript": "~5.6.2", - "vite": "catalog:", - }, - }, - "packages/desktop-electron": { - "name": "@opencode-ai/desktop-electron", - "version": "1.14.35", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -1570,8 +1535,6 @@ "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], - "@opencode-ai/desktop-electron": ["@opencode-ai/desktop-electron@workspace:packages/desktop-electron"], - "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], @@ -2270,54 +2233,8 @@ "@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], - "@tauri-apps/cli": ["@tauri-apps/cli@2.10.1", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.1", "@tauri-apps/cli-darwin-x64": "2.10.1", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", "@tauri-apps/cli-linux-arm64-musl": "2.10.1", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-gnu": "2.10.1", "@tauri-apps/cli-linux-x64-musl": "2.10.1", "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", "@tauri-apps/cli-win32-x64-msvc": "2.10.1" }, "bin": { "tauri": "tauri.js" } }, "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g=="], - - "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ=="], - - "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw=="], - - "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w=="], - - "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA=="], - - "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg=="], - - "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.1", "", { "os": "linux", "cpu": "none" }, "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw=="], - - "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw=="], - - "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ=="], - - "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg=="], - - "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw=="], - - "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="], - - "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], - - "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.8", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-Cd2Cs960MGuGONeIwxOPx9wqwedetAHOGlwK5boJ/SMTfAtAyfErpfVPEn+EJzgXsJun8EKzsEumHjr+64V4fw=="], - - "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.0", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw=="], - - "@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.8", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-oxd7oypzQeu8kAfFCrw534Kq7Cw+NzozcnCY21O4rz3A+veJiIiuSCMIprgGcZOcLAXFP9GmDhKUbhuKWcunRw=="], - - "@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="], - - "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="], - - "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], - - "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], - - "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="], - "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A=="], - "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], - - "@tauri-apps/plugin-window-state": ["@tauri-apps/plugin-window-state@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="], - "@tediousjs/connection-string": ["@tediousjs/connection-string@0.5.0", "", {}, "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -5628,13 +5545,9 @@ "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], - "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], - - "@opencode-ai/desktop-electron/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], - - "@opencode-ai/desktop-electron/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], - "@opencode-ai/desktop-electron/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], @@ -6618,8 +6531,6 @@ "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], - "@opencode-ai/desktop-electron/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], - "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], @@ -7068,8 +6979,6 @@ "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], - "@opencode-ai/desktop-electron/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], - "@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], diff --git a/package.json b/package.json index de3dd31f40..335a8b3b1d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "packageManager": "bun@1.3.13", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", - "dev:desktop": "bun --cwd packages/desktop-electron dev", + "dev:desktop": "bun --cwd packages/desktop dev", "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", diff --git a/packages/desktop-electron/.gitignore b/packages/desktop-electron/.gitignore deleted file mode 100644 index ac9d8db969..0000000000 --- a/packages/desktop-electron/.gitignore +++ /dev/null @@ -1,28 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -out/ - -resources/opencode-cli* -resources/icons diff --git a/packages/desktop-electron/AGENTS.md b/packages/desktop-electron/AGENTS.md deleted file mode 100644 index 7805ea835f..0000000000 --- a/packages/desktop-electron/AGENTS.md +++ /dev/null @@ -1,4 +0,0 @@ -# Desktop package notes - -- Renderer process should only call `window.api` from `src/preload`. -- Main process should register IPC handlers in `src/main/ipc.ts`. diff --git a/packages/desktop-electron/README.md b/packages/desktop-electron/README.md deleted file mode 100644 index ebaf488223..0000000000 --- a/packages/desktop-electron/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# OpenCode Desktop - -Native OpenCode desktop app, built with Tauri v2. - -## Development - -From the repo root: - -```bash -bun install -bun run --cwd packages/desktop tauri dev -``` - -This starts the Vite dev server on http://localhost:1420 and opens the native window. - -If you only want the web dev server (no native shell): - -```bash -bun run --cwd packages/desktop dev -``` - -## Build - -To create a production `dist/` and build the native app bundle: - -```bash -bun run --cwd packages/desktop tauri build -``` - -## Prerequisites - -Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json deleted file mode 100644 index ba981e637a..0000000000 --- a/packages/desktop-electron/package.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "name": "@opencode-ai/desktop-electron", - "private": true, - "version": "1.14.35", - "type": "module", - "license": "MIT", - "homepage": "https://opencode.ai", - "author": { - "name": "OpenCode", - "email": "hello@opencode.ai" - }, - "scripts": { - "typecheck": "tsgo -b", - "predev": "bun ./scripts/predev.ts", - "dev": "electron-vite dev", - "prebuild": "bun ./scripts/prebuild.ts", - "build": "electron-vite build", - "preview": "electron-vite preview", - "package": "electron-builder --config electron-builder.config.ts", - "package:mac": "electron-builder --mac --config electron-builder.config.ts", - "package:win": "electron-builder --win --config electron-builder.config.ts", - "package:linux": "electron-builder --linux --config electron-builder.config.ts", - "native:build": "bun install --cwd native" - }, - "main": "./out/main/index.js", - "dependencies": { - "effect": "catalog:", - "electron-context-menu": "4.1.2", - "electron-log": "^5", - "electron-store": "^10", - "electron-updater": "^6", - "electron-window-state": "^5.0.3", - "drizzle-orm": "catalog:", - "marked": "^15" - }, - "devDependencies": { - "@actions/artifact": "4.0.0", - "@lydell/node-pty": "catalog:", - "@opencode-ai/app": "workspace:*", - "@opencode-ai/ui": "workspace:*", - "@sentry/solid": "catalog:", - "@sentry/vite-plugin": "catalog:", - "@solid-primitives/i18n": "2.2.1", - "@solid-primitives/storage": "catalog:", - "@solidjs/meta": "catalog:", - "@solidjs/router": "0.15.4", - "@types/bun": "catalog:", - "@types/node": "catalog:", - "@typescript/native-preview": "catalog:", - "@valibot/to-json-schema": "1.6.0", - "electron": "41.2.1", - "electron-builder": "^26", - "electron-vite": "^5", - "solid-js": "catalog:", - "sury": "11.0.0-alpha.4", - "typescript": "~5.6.2", - "vite": "catalog:", - "zod-openapi": "5.4.6" - }, - "optionalDependencies": { - "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", - "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", - "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", - "@lydell/node-pty-linux-x64": "1.2.0-beta.10", - "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", - "@lydell/node-pty-win32-x64": "1.2.0-beta.10" - } -} diff --git a/packages/desktop-electron/scripts/copy-bundles.ts b/packages/desktop-electron/scripts/copy-bundles.ts deleted file mode 100644 index 6ef3335eb7..0000000000 --- a/packages/desktop-electron/scripts/copy-bundles.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { $ } from "bun" -import * as path from "node:path" - -import { RUST_TARGET } from "./utils" - -if (!RUST_TARGET) throw new Error("RUST_TARGET not defined") - -const BUNDLE_DIR = "dist" -const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles") - -await $`mkdir -p ${BUNDLES_OUT_DIR}` -await $`cp -r ${BUNDLE_DIR}/* ${BUNDLES_OUT_DIR}` diff --git a/packages/desktop-electron/scripts/predev.ts b/packages/desktop-electron/scripts/predev.ts deleted file mode 100644 index 37c31d7eed..0000000000 --- a/packages/desktop-electron/scripts/predev.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { $ } from "bun" - -await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}` - -await $`cd ../opencode && bun script/build-node.ts` diff --git a/packages/desktop-electron/scripts/prepare.ts b/packages/desktop-electron/scripts/prepare.ts deleted file mode 100755 index 0dfd5a35cb..0000000000 --- a/packages/desktop-electron/scripts/prepare.ts +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bun -import { Script } from "@opencode-ai/script" - -await import("./prebuild") - -const pkg = await Bun.file("./package.json").json() -pkg.version = Script.version -await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n") -console.log(`Updated package.json version to ${Script.version}`) diff --git a/packages/desktop-electron/scripts/utils.ts b/packages/desktop-electron/scripts/utils.ts deleted file mode 100644 index 19b96b0a16..0000000000 --- a/packages/desktop-electron/scripts/utils.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { $ } from "bun" - -export type Channel = "dev" | "beta" | "prod" - -export function resolveChannel(): Channel { - const raw = Bun.env.OPENCODE_CHANNEL - if (raw === "dev" || raw === "beta" || raw === "prod") return raw - return "dev" -} - -export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [ - { - rustTarget: "aarch64-apple-darwin", - ocBinary: "opencode-darwin-arm64", - assetExt: "zip", - }, - { - rustTarget: "x86_64-apple-darwin", - ocBinary: "opencode-darwin-x64-baseline", - assetExt: "zip", - }, - { - rustTarget: "aarch64-pc-windows-msvc", - ocBinary: "opencode-windows-arm64", - assetExt: "zip", - }, - { - rustTarget: "x86_64-pc-windows-msvc", - ocBinary: "opencode-windows-x64-baseline", - assetExt: "zip", - }, - { - rustTarget: "x86_64-unknown-linux-gnu", - ocBinary: "opencode-linux-x64-baseline", - assetExt: "tar.gz", - }, - { - rustTarget: "aarch64-unknown-linux-gnu", - ocBinary: "opencode-linux-arm64", - assetExt: "tar.gz", - }, -] - -export const RUST_TARGET = Bun.env.RUST_TARGET - -function nativeTarget() { - const { platform, arch } = process - if (platform === "darwin") return arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin" - if (platform === "win32") return arch === "arm64" ? "aarch64-pc-windows-msvc" : "x86_64-pc-windows-msvc" - if (platform === "linux") return arch === "arm64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu" - throw new Error(`Unsupported platform: ${platform}/${arch}`) -} - -export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) { - const binaryConfig = SIDECAR_BINARIES.find((b) => b.rustTarget === target) - if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`) - - return binaryConfig -} - -export async function copyBinaryToSidecarFolder(source: string) { - const dir = `resources` - await $`mkdir -p ${dir}` - const dest = windowsify(`${dir}/opencode-cli`) - await $`cp ${source} ${dest}` - if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { - await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}` - } - if (process.platform === "darwin") await $`codesign --force --sign - ${dest}` - - console.log(`Copied ${source} to ${dest}`) -} - -export function windowsify(path: string) { - if (path.endsWith(".exe")) return path - return `${path}${process.platform === "win32" ? ".exe" : ""}` -} diff --git a/packages/desktop-electron/sst-env.d.ts b/packages/desktop-electron/sst-env.d.ts deleted file mode 100644 index 64441936d7..0000000000 --- a/packages/desktop-electron/sst-env.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* This file is auto-generated by SST. Do not edit. */ -/* tslint:disable */ -/* eslint-disable */ -/* deno-fmt-ignore-file */ -/* biome-ignore-all lint: auto-generated */ - -/// - -import "sst" -export {} \ No newline at end of file diff --git a/packages/desktop-electron/tsconfig.json b/packages/desktop-electron/tsconfig.json deleted file mode 100644 index 9637fe03dd..0000000000 --- a/packages/desktop-electron/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "jsx": "preserve", - "jsxImportSource": "solid-js", - "allowJs": true, - "resolveJsonModule": true, - "strict": true, - "isolatedModules": true, - "noEmit": true, - "emitDeclarationOnly": false, - "outDir": "node_modules/.ts-dist", - "types": ["vite/client", "node", "electron"] - }, - "references": [{ "path": "../app" }], - "include": ["src", "package.json"], - "exclude": ["src/**/*.test.ts"] -} diff --git a/packages/desktop/.gitignore b/packages/desktop/.gitignore index a547bf36d8..ac9d8db969 100644 --- a/packages/desktop/.gitignore +++ b/packages/desktop/.gitignore @@ -22,3 +22,7 @@ dist-ssr *.njsproj *.sln *.sw? +out/ + +resources/opencode-cli* +resources/icons diff --git a/packages/desktop/AGENTS.md b/packages/desktop/AGENTS.md index 3839db1a90..7805ea835f 100644 --- a/packages/desktop/AGENTS.md +++ b/packages/desktop/AGENTS.md @@ -1,4 +1,4 @@ # Desktop package notes -- Never call `invoke` manually in this package. -- Use the generated bindings in `packages/desktop/src/bindings.ts` for core commands/events. +- Renderer process should only call `window.api` from `src/preload`. +- Main process should register IPC handlers in `src/main/ipc.ts`. diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 358b7d24d5..ebaf488223 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -2,10 +2,6 @@ Native OpenCode desktop app, built with Tauri v2. -## Prerequisites - -Building the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. - ## Development From the repo root: @@ -15,18 +11,22 @@ bun install bun run --cwd packages/desktop tauri dev ``` -## Build +This starts the Vite dev server on http://localhost:1420 and opens the native window. + +If you only want the web dev server (no native shell): ```bash -bun run --cwd packages/desktop tauri build +bun run --cwd packages/desktop dev ``` -## Troubleshooting - -### Rust compiler not found +## Build -If you see errors about Rust not being found, install it via [rustup](https://rustup.rs/): +To create a production `dist/` and build the native app bundle: ```bash -curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +bun run --cwd packages/desktop tauri build ``` + +## Prerequisites + +Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions. diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts similarity index 100% rename from packages/desktop-electron/electron-builder.config.ts rename to packages/desktop/electron-builder.config.ts diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts similarity index 100% rename from packages/desktop-electron/electron.vite.config.ts rename to packages/desktop/electron.vite.config.ts diff --git a/packages/desktop-electron/icons/README.md b/packages/desktop/icons/README.md similarity index 100% rename from packages/desktop-electron/icons/README.md rename to packages/desktop/icons/README.md diff --git a/packages/desktop-electron/icons/beta/128x128.png b/packages/desktop/icons/beta/128x128.png similarity index 100% rename from packages/desktop-electron/icons/beta/128x128.png rename to packages/desktop/icons/beta/128x128.png diff --git a/packages/desktop-electron/icons/beta/128x128@2x.png b/packages/desktop/icons/beta/128x128@2x.png similarity index 100% rename from packages/desktop-electron/icons/beta/128x128@2x.png rename to packages/desktop/icons/beta/128x128@2x.png diff --git a/packages/desktop-electron/icons/beta/32x32.png b/packages/desktop/icons/beta/32x32.png similarity index 100% rename from packages/desktop-electron/icons/beta/32x32.png rename to packages/desktop/icons/beta/32x32.png diff --git a/packages/desktop-electron/icons/beta/64x64.png b/packages/desktop/icons/beta/64x64.png similarity index 100% rename from packages/desktop-electron/icons/beta/64x64.png rename to packages/desktop/icons/beta/64x64.png diff --git a/packages/desktop-electron/icons/beta/Square107x107Logo.png b/packages/desktop/icons/beta/Square107x107Logo.png similarity index 100% rename from packages/desktop-electron/icons/beta/Square107x107Logo.png rename to packages/desktop/icons/beta/Square107x107Logo.png diff --git a/packages/desktop-electron/icons/beta/Square142x142Logo.png b/packages/desktop/icons/beta/Square142x142Logo.png similarity index 100% rename from packages/desktop-electron/icons/beta/Square142x142Logo.png rename to packages/desktop/icons/beta/Square142x142Logo.png diff --git a/packages/desktop-electron/icons/beta/Square150x150Logo.png b/packages/desktop/icons/beta/Square150x150Logo.png similarity index 100% rename from packages/desktop-electron/icons/beta/Square150x150Logo.png rename to packages/desktop/icons/beta/Square150x150Logo.png diff --git a/packages/desktop-electron/icons/beta/Square284x284Logo.png b/packages/desktop/icons/beta/Square284x284Logo.png similarity index 100% rename from packages/desktop-electron/icons/beta/Square284x284Logo.png rename to packages/desktop/icons/beta/Square284x284Logo.png diff --git a/packages/desktop-electron/icons/beta/Square30x30Logo.png b/packages/desktop/icons/beta/Square30x30Logo.png similarity index 100% rename from packages/desktop-electron/icons/beta/Square30x30Logo.png rename to packages/desktop/icons/beta/Square30x30Logo.png diff --git a/packages/desktop-electron/icons/beta/Square310x310Logo.png b/packages/desktop/icons/beta/Square310x310Logo.png similarity index 100% rename from packages/desktop-electron/icons/beta/Square310x310Logo.png rename to packages/desktop/icons/beta/Square310x310Logo.png diff --git a/packages/desktop-electron/icons/beta/Square44x44Logo.png b/packages/desktop/icons/beta/Square44x44Logo.png similarity index 100% rename from packages/desktop-electron/icons/beta/Square44x44Logo.png rename to packages/desktop/icons/beta/Square44x44Logo.png diff --git a/packages/desktop-electron/icons/beta/Square71x71Logo.png b/packages/desktop/icons/beta/Square71x71Logo.png similarity index 100% rename from packages/desktop-electron/icons/beta/Square71x71Logo.png rename to packages/desktop/icons/beta/Square71x71Logo.png diff --git a/packages/desktop-electron/icons/beta/Square89x89Logo.png b/packages/desktop/icons/beta/Square89x89Logo.png similarity index 100% rename from packages/desktop-electron/icons/beta/Square89x89Logo.png rename to packages/desktop/icons/beta/Square89x89Logo.png diff --git a/packages/desktop-electron/icons/beta/StoreLogo.png b/packages/desktop/icons/beta/StoreLogo.png similarity index 100% rename from packages/desktop-electron/icons/beta/StoreLogo.png rename to packages/desktop/icons/beta/StoreLogo.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml b/packages/desktop/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml rename to packages/desktop/icons/beta/android/mipmap-anydpi-v26/ic_launcher.xml diff --git a/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher.png b/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher.png rename to packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png b/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png rename to packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_round.png b/packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-hdpi/ic_launcher_round.png rename to packages/desktop/icons/beta/android/mipmap-hdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher.png b/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher.png rename to packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png b/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png rename to packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_round.png b/packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-mdpi/ic_launcher_round.png rename to packages/desktop/icons/beta/android/mipmap-mdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher.png b/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher.png rename to packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png b/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png rename to packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png b/packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png rename to packages/desktop/icons/beta/android/mipmap-xhdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher.png b/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher.png rename to packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png rename to packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png b/packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png rename to packages/desktop/icons/beta/android/mipmap-xxhdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png b/packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png rename to packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png rename to packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png b/packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png rename to packages/desktop/icons/beta/android/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/beta/android/values/ic_launcher_background.xml b/packages/desktop/icons/beta/android/values/ic_launcher_background.xml similarity index 100% rename from packages/desktop-electron/icons/beta/android/values/ic_launcher_background.xml rename to packages/desktop/icons/beta/android/values/ic_launcher_background.xml diff --git a/packages/desktop-electron/icons/beta/dock.png b/packages/desktop/icons/beta/dock.png similarity index 100% rename from packages/desktop-electron/icons/beta/dock.png rename to packages/desktop/icons/beta/dock.png diff --git a/packages/desktop-electron/icons/beta/icon.icns b/packages/desktop/icons/beta/icon.icns similarity index 100% rename from packages/desktop-electron/icons/beta/icon.icns rename to packages/desktop/icons/beta/icon.icns diff --git a/packages/desktop-electron/icons/beta/icon.ico b/packages/desktop/icons/beta/icon.ico similarity index 100% rename from packages/desktop-electron/icons/beta/icon.ico rename to packages/desktop/icons/beta/icon.ico diff --git a/packages/desktop-electron/icons/beta/icon.png b/packages/desktop/icons/beta/icon.png similarity index 100% rename from packages/desktop-electron/icons/beta/icon.png rename to packages/desktop/icons/beta/icon.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-20x20@1x.png b/packages/desktop/icons/beta/ios/AppIcon-20x20@1x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-20x20@1x.png rename to packages/desktop/icons/beta/ios/AppIcon-20x20@1x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-20x20@2x-1.png b/packages/desktop/icons/beta/ios/AppIcon-20x20@2x-1.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-20x20@2x-1.png rename to packages/desktop/icons/beta/ios/AppIcon-20x20@2x-1.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-20x20@2x.png b/packages/desktop/icons/beta/ios/AppIcon-20x20@2x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-20x20@2x.png rename to packages/desktop/icons/beta/ios/AppIcon-20x20@2x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-20x20@3x.png b/packages/desktop/icons/beta/ios/AppIcon-20x20@3x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-20x20@3x.png rename to packages/desktop/icons/beta/ios/AppIcon-20x20@3x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-29x29@1x.png b/packages/desktop/icons/beta/ios/AppIcon-29x29@1x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-29x29@1x.png rename to packages/desktop/icons/beta/ios/AppIcon-29x29@1x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-29x29@2x-1.png b/packages/desktop/icons/beta/ios/AppIcon-29x29@2x-1.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-29x29@2x-1.png rename to packages/desktop/icons/beta/ios/AppIcon-29x29@2x-1.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-29x29@2x.png b/packages/desktop/icons/beta/ios/AppIcon-29x29@2x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-29x29@2x.png rename to packages/desktop/icons/beta/ios/AppIcon-29x29@2x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-29x29@3x.png b/packages/desktop/icons/beta/ios/AppIcon-29x29@3x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-29x29@3x.png rename to packages/desktop/icons/beta/ios/AppIcon-29x29@3x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-40x40@1x.png b/packages/desktop/icons/beta/ios/AppIcon-40x40@1x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-40x40@1x.png rename to packages/desktop/icons/beta/ios/AppIcon-40x40@1x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-40x40@2x-1.png b/packages/desktop/icons/beta/ios/AppIcon-40x40@2x-1.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-40x40@2x-1.png rename to packages/desktop/icons/beta/ios/AppIcon-40x40@2x-1.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-40x40@2x.png b/packages/desktop/icons/beta/ios/AppIcon-40x40@2x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-40x40@2x.png rename to packages/desktop/icons/beta/ios/AppIcon-40x40@2x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-40x40@3x.png b/packages/desktop/icons/beta/ios/AppIcon-40x40@3x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-40x40@3x.png rename to packages/desktop/icons/beta/ios/AppIcon-40x40@3x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-512@2x.png b/packages/desktop/icons/beta/ios/AppIcon-512@2x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-512@2x.png rename to packages/desktop/icons/beta/ios/AppIcon-512@2x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-60x60@2x.png b/packages/desktop/icons/beta/ios/AppIcon-60x60@2x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-60x60@2x.png rename to packages/desktop/icons/beta/ios/AppIcon-60x60@2x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-60x60@3x.png b/packages/desktop/icons/beta/ios/AppIcon-60x60@3x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-60x60@3x.png rename to packages/desktop/icons/beta/ios/AppIcon-60x60@3x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-76x76@1x.png b/packages/desktop/icons/beta/ios/AppIcon-76x76@1x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-76x76@1x.png rename to packages/desktop/icons/beta/ios/AppIcon-76x76@1x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-76x76@2x.png b/packages/desktop/icons/beta/ios/AppIcon-76x76@2x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-76x76@2x.png rename to packages/desktop/icons/beta/ios/AppIcon-76x76@2x.png diff --git a/packages/desktop-electron/icons/beta/ios/AppIcon-83.5x83.5@2x.png b/packages/desktop/icons/beta/ios/AppIcon-83.5x83.5@2x.png similarity index 100% rename from packages/desktop-electron/icons/beta/ios/AppIcon-83.5x83.5@2x.png rename to packages/desktop/icons/beta/ios/AppIcon-83.5x83.5@2x.png diff --git a/packages/desktop-electron/icons/dev/128x128.png b/packages/desktop/icons/dev/128x128.png similarity index 100% rename from packages/desktop-electron/icons/dev/128x128.png rename to packages/desktop/icons/dev/128x128.png diff --git a/packages/desktop-electron/icons/dev/128x128@2x.png b/packages/desktop/icons/dev/128x128@2x.png similarity index 100% rename from packages/desktop-electron/icons/dev/128x128@2x.png rename to packages/desktop/icons/dev/128x128@2x.png diff --git a/packages/desktop-electron/icons/dev/32x32.png b/packages/desktop/icons/dev/32x32.png similarity index 100% rename from packages/desktop-electron/icons/dev/32x32.png rename to packages/desktop/icons/dev/32x32.png diff --git a/packages/desktop-electron/icons/dev/64x64.png b/packages/desktop/icons/dev/64x64.png similarity index 100% rename from packages/desktop-electron/icons/dev/64x64.png rename to packages/desktop/icons/dev/64x64.png diff --git a/packages/desktop-electron/icons/dev/Square107x107Logo.png b/packages/desktop/icons/dev/Square107x107Logo.png similarity index 100% rename from packages/desktop-electron/icons/dev/Square107x107Logo.png rename to packages/desktop/icons/dev/Square107x107Logo.png diff --git a/packages/desktop-electron/icons/dev/Square142x142Logo.png b/packages/desktop/icons/dev/Square142x142Logo.png similarity index 100% rename from packages/desktop-electron/icons/dev/Square142x142Logo.png rename to packages/desktop/icons/dev/Square142x142Logo.png diff --git a/packages/desktop-electron/icons/dev/Square150x150Logo.png b/packages/desktop/icons/dev/Square150x150Logo.png similarity index 100% rename from packages/desktop-electron/icons/dev/Square150x150Logo.png rename to packages/desktop/icons/dev/Square150x150Logo.png diff --git a/packages/desktop-electron/icons/dev/Square284x284Logo.png b/packages/desktop/icons/dev/Square284x284Logo.png similarity index 100% rename from packages/desktop-electron/icons/dev/Square284x284Logo.png rename to packages/desktop/icons/dev/Square284x284Logo.png diff --git a/packages/desktop-electron/icons/dev/Square30x30Logo.png b/packages/desktop/icons/dev/Square30x30Logo.png similarity index 100% rename from packages/desktop-electron/icons/dev/Square30x30Logo.png rename to packages/desktop/icons/dev/Square30x30Logo.png diff --git a/packages/desktop-electron/icons/dev/Square310x310Logo.png b/packages/desktop/icons/dev/Square310x310Logo.png similarity index 100% rename from packages/desktop-electron/icons/dev/Square310x310Logo.png rename to packages/desktop/icons/dev/Square310x310Logo.png diff --git a/packages/desktop-electron/icons/dev/Square44x44Logo.png b/packages/desktop/icons/dev/Square44x44Logo.png similarity index 100% rename from packages/desktop-electron/icons/dev/Square44x44Logo.png rename to packages/desktop/icons/dev/Square44x44Logo.png diff --git a/packages/desktop-electron/icons/dev/Square71x71Logo.png b/packages/desktop/icons/dev/Square71x71Logo.png similarity index 100% rename from packages/desktop-electron/icons/dev/Square71x71Logo.png rename to packages/desktop/icons/dev/Square71x71Logo.png diff --git a/packages/desktop-electron/icons/dev/Square89x89Logo.png b/packages/desktop/icons/dev/Square89x89Logo.png similarity index 100% rename from packages/desktop-electron/icons/dev/Square89x89Logo.png rename to packages/desktop/icons/dev/Square89x89Logo.png diff --git a/packages/desktop-electron/icons/dev/StoreLogo.png b/packages/desktop/icons/dev/StoreLogo.png similarity index 100% rename from packages/desktop-electron/icons/dev/StoreLogo.png rename to packages/desktop/icons/dev/StoreLogo.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml b/packages/desktop/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml rename to packages/desktop/icons/dev/android/mipmap-anydpi-v26/ic_launcher.xml diff --git a/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher.png b/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher.png rename to packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png b/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png rename to packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_round.png b/packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-hdpi/ic_launcher_round.png rename to packages/desktop/icons/dev/android/mipmap-hdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher.png b/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher.png rename to packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png b/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png rename to packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_round.png b/packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-mdpi/ic_launcher_round.png rename to packages/desktop/icons/dev/android/mipmap-mdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher.png b/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher.png rename to packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png b/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png rename to packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png b/packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png rename to packages/desktop/icons/dev/android/mipmap-xhdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher.png b/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher.png rename to packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png rename to packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png b/packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png rename to packages/desktop/icons/dev/android/mipmap-xxhdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png b/packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png rename to packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png rename to packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png b/packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png rename to packages/desktop/icons/dev/android/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/dev/android/values/ic_launcher_background.xml b/packages/desktop/icons/dev/android/values/ic_launcher_background.xml similarity index 100% rename from packages/desktop-electron/icons/dev/android/values/ic_launcher_background.xml rename to packages/desktop/icons/dev/android/values/ic_launcher_background.xml diff --git a/packages/desktop-electron/icons/dev/dock.png b/packages/desktop/icons/dev/dock.png similarity index 100% rename from packages/desktop-electron/icons/dev/dock.png rename to packages/desktop/icons/dev/dock.png diff --git a/packages/desktop-electron/icons/dev/icon.icns b/packages/desktop/icons/dev/icon.icns similarity index 100% rename from packages/desktop-electron/icons/dev/icon.icns rename to packages/desktop/icons/dev/icon.icns diff --git a/packages/desktop-electron/icons/dev/icon.ico b/packages/desktop/icons/dev/icon.ico similarity index 100% rename from packages/desktop-electron/icons/dev/icon.ico rename to packages/desktop/icons/dev/icon.ico diff --git a/packages/desktop-electron/icons/dev/icon.png b/packages/desktop/icons/dev/icon.png similarity index 100% rename from packages/desktop-electron/icons/dev/icon.png rename to packages/desktop/icons/dev/icon.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-20x20@1x.png b/packages/desktop/icons/dev/ios/AppIcon-20x20@1x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-20x20@1x.png rename to packages/desktop/icons/dev/ios/AppIcon-20x20@1x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-20x20@2x-1.png b/packages/desktop/icons/dev/ios/AppIcon-20x20@2x-1.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-20x20@2x-1.png rename to packages/desktop/icons/dev/ios/AppIcon-20x20@2x-1.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-20x20@2x.png b/packages/desktop/icons/dev/ios/AppIcon-20x20@2x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-20x20@2x.png rename to packages/desktop/icons/dev/ios/AppIcon-20x20@2x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-20x20@3x.png b/packages/desktop/icons/dev/ios/AppIcon-20x20@3x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-20x20@3x.png rename to packages/desktop/icons/dev/ios/AppIcon-20x20@3x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-29x29@1x.png b/packages/desktop/icons/dev/ios/AppIcon-29x29@1x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-29x29@1x.png rename to packages/desktop/icons/dev/ios/AppIcon-29x29@1x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-29x29@2x-1.png b/packages/desktop/icons/dev/ios/AppIcon-29x29@2x-1.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-29x29@2x-1.png rename to packages/desktop/icons/dev/ios/AppIcon-29x29@2x-1.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-29x29@2x.png b/packages/desktop/icons/dev/ios/AppIcon-29x29@2x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-29x29@2x.png rename to packages/desktop/icons/dev/ios/AppIcon-29x29@2x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-29x29@3x.png b/packages/desktop/icons/dev/ios/AppIcon-29x29@3x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-29x29@3x.png rename to packages/desktop/icons/dev/ios/AppIcon-29x29@3x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-40x40@1x.png b/packages/desktop/icons/dev/ios/AppIcon-40x40@1x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-40x40@1x.png rename to packages/desktop/icons/dev/ios/AppIcon-40x40@1x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-40x40@2x-1.png b/packages/desktop/icons/dev/ios/AppIcon-40x40@2x-1.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-40x40@2x-1.png rename to packages/desktop/icons/dev/ios/AppIcon-40x40@2x-1.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-40x40@2x.png b/packages/desktop/icons/dev/ios/AppIcon-40x40@2x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-40x40@2x.png rename to packages/desktop/icons/dev/ios/AppIcon-40x40@2x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-40x40@3x.png b/packages/desktop/icons/dev/ios/AppIcon-40x40@3x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-40x40@3x.png rename to packages/desktop/icons/dev/ios/AppIcon-40x40@3x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-512@2x.png b/packages/desktop/icons/dev/ios/AppIcon-512@2x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-512@2x.png rename to packages/desktop/icons/dev/ios/AppIcon-512@2x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-60x60@2x.png b/packages/desktop/icons/dev/ios/AppIcon-60x60@2x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-60x60@2x.png rename to packages/desktop/icons/dev/ios/AppIcon-60x60@2x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-60x60@3x.png b/packages/desktop/icons/dev/ios/AppIcon-60x60@3x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-60x60@3x.png rename to packages/desktop/icons/dev/ios/AppIcon-60x60@3x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-76x76@1x.png b/packages/desktop/icons/dev/ios/AppIcon-76x76@1x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-76x76@1x.png rename to packages/desktop/icons/dev/ios/AppIcon-76x76@1x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-76x76@2x.png b/packages/desktop/icons/dev/ios/AppIcon-76x76@2x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-76x76@2x.png rename to packages/desktop/icons/dev/ios/AppIcon-76x76@2x.png diff --git a/packages/desktop-electron/icons/dev/ios/AppIcon-83.5x83.5@2x.png b/packages/desktop/icons/dev/ios/AppIcon-83.5x83.5@2x.png similarity index 100% rename from packages/desktop-electron/icons/dev/ios/AppIcon-83.5x83.5@2x.png rename to packages/desktop/icons/dev/ios/AppIcon-83.5x83.5@2x.png diff --git a/packages/desktop-electron/icons/prod/128x128.png b/packages/desktop/icons/prod/128x128.png similarity index 100% rename from packages/desktop-electron/icons/prod/128x128.png rename to packages/desktop/icons/prod/128x128.png diff --git a/packages/desktop-electron/icons/prod/128x128@2x.png b/packages/desktop/icons/prod/128x128@2x.png similarity index 100% rename from packages/desktop-electron/icons/prod/128x128@2x.png rename to packages/desktop/icons/prod/128x128@2x.png diff --git a/packages/desktop-electron/icons/prod/32x32.png b/packages/desktop/icons/prod/32x32.png similarity index 100% rename from packages/desktop-electron/icons/prod/32x32.png rename to packages/desktop/icons/prod/32x32.png diff --git a/packages/desktop-electron/icons/prod/64x64.png b/packages/desktop/icons/prod/64x64.png similarity index 100% rename from packages/desktop-electron/icons/prod/64x64.png rename to packages/desktop/icons/prod/64x64.png diff --git a/packages/desktop-electron/icons/prod/Square107x107Logo.png b/packages/desktop/icons/prod/Square107x107Logo.png similarity index 100% rename from packages/desktop-electron/icons/prod/Square107x107Logo.png rename to packages/desktop/icons/prod/Square107x107Logo.png diff --git a/packages/desktop-electron/icons/prod/Square142x142Logo.png b/packages/desktop/icons/prod/Square142x142Logo.png similarity index 100% rename from packages/desktop-electron/icons/prod/Square142x142Logo.png rename to packages/desktop/icons/prod/Square142x142Logo.png diff --git a/packages/desktop-electron/icons/prod/Square150x150Logo.png b/packages/desktop/icons/prod/Square150x150Logo.png similarity index 100% rename from packages/desktop-electron/icons/prod/Square150x150Logo.png rename to packages/desktop/icons/prod/Square150x150Logo.png diff --git a/packages/desktop-electron/icons/prod/Square284x284Logo.png b/packages/desktop/icons/prod/Square284x284Logo.png similarity index 100% rename from packages/desktop-electron/icons/prod/Square284x284Logo.png rename to packages/desktop/icons/prod/Square284x284Logo.png diff --git a/packages/desktop-electron/icons/prod/Square30x30Logo.png b/packages/desktop/icons/prod/Square30x30Logo.png similarity index 100% rename from packages/desktop-electron/icons/prod/Square30x30Logo.png rename to packages/desktop/icons/prod/Square30x30Logo.png diff --git a/packages/desktop-electron/icons/prod/Square310x310Logo.png b/packages/desktop/icons/prod/Square310x310Logo.png similarity index 100% rename from packages/desktop-electron/icons/prod/Square310x310Logo.png rename to packages/desktop/icons/prod/Square310x310Logo.png diff --git a/packages/desktop-electron/icons/prod/Square44x44Logo.png b/packages/desktop/icons/prod/Square44x44Logo.png similarity index 100% rename from packages/desktop-electron/icons/prod/Square44x44Logo.png rename to packages/desktop/icons/prod/Square44x44Logo.png diff --git a/packages/desktop-electron/icons/prod/Square71x71Logo.png b/packages/desktop/icons/prod/Square71x71Logo.png similarity index 100% rename from packages/desktop-electron/icons/prod/Square71x71Logo.png rename to packages/desktop/icons/prod/Square71x71Logo.png diff --git a/packages/desktop-electron/icons/prod/Square89x89Logo.png b/packages/desktop/icons/prod/Square89x89Logo.png similarity index 100% rename from packages/desktop-electron/icons/prod/Square89x89Logo.png rename to packages/desktop/icons/prod/Square89x89Logo.png diff --git a/packages/desktop-electron/icons/prod/StoreLogo.png b/packages/desktop/icons/prod/StoreLogo.png similarity index 100% rename from packages/desktop-electron/icons/prod/StoreLogo.png rename to packages/desktop/icons/prod/StoreLogo.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml b/packages/desktop/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml rename to packages/desktop/icons/prod/android/mipmap-anydpi-v26/ic_launcher.xml diff --git a/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher.png b/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher.png rename to packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png b/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png rename to packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_round.png b/packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-hdpi/ic_launcher_round.png rename to packages/desktop/icons/prod/android/mipmap-hdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher.png b/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher.png rename to packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png b/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png rename to packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_round.png b/packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-mdpi/ic_launcher_round.png rename to packages/desktop/icons/prod/android/mipmap-mdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher.png b/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher.png rename to packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png b/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png rename to packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png b/packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png rename to packages/desktop/icons/prod/android/mipmap-xhdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher.png b/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher.png rename to packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png b/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png rename to packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png b/packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png rename to packages/desktop/icons/prod/android/mipmap-xxhdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png b/packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png rename to packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png rename to packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher_foreground.png diff --git a/packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png b/packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from packages/desktop-electron/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png rename to packages/desktop/icons/prod/android/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/packages/desktop-electron/icons/prod/android/values/ic_launcher_background.xml b/packages/desktop/icons/prod/android/values/ic_launcher_background.xml similarity index 100% rename from packages/desktop-electron/icons/prod/android/values/ic_launcher_background.xml rename to packages/desktop/icons/prod/android/values/ic_launcher_background.xml diff --git a/packages/desktop-electron/icons/prod/dock.png b/packages/desktop/icons/prod/dock.png similarity index 100% rename from packages/desktop-electron/icons/prod/dock.png rename to packages/desktop/icons/prod/dock.png diff --git a/packages/desktop-electron/icons/prod/icon.icns b/packages/desktop/icons/prod/icon.icns similarity index 100% rename from packages/desktop-electron/icons/prod/icon.icns rename to packages/desktop/icons/prod/icon.icns diff --git a/packages/desktop-electron/icons/prod/icon.ico b/packages/desktop/icons/prod/icon.ico similarity index 100% rename from packages/desktop-electron/icons/prod/icon.ico rename to packages/desktop/icons/prod/icon.ico diff --git a/packages/desktop-electron/icons/prod/icon.png b/packages/desktop/icons/prod/icon.png similarity index 100% rename from packages/desktop-electron/icons/prod/icon.png rename to packages/desktop/icons/prod/icon.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-20x20@1x.png b/packages/desktop/icons/prod/ios/AppIcon-20x20@1x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-20x20@1x.png rename to packages/desktop/icons/prod/ios/AppIcon-20x20@1x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-20x20@2x-1.png b/packages/desktop/icons/prod/ios/AppIcon-20x20@2x-1.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-20x20@2x-1.png rename to packages/desktop/icons/prod/ios/AppIcon-20x20@2x-1.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-20x20@2x.png b/packages/desktop/icons/prod/ios/AppIcon-20x20@2x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-20x20@2x.png rename to packages/desktop/icons/prod/ios/AppIcon-20x20@2x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-20x20@3x.png b/packages/desktop/icons/prod/ios/AppIcon-20x20@3x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-20x20@3x.png rename to packages/desktop/icons/prod/ios/AppIcon-20x20@3x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-29x29@1x.png b/packages/desktop/icons/prod/ios/AppIcon-29x29@1x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-29x29@1x.png rename to packages/desktop/icons/prod/ios/AppIcon-29x29@1x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-29x29@2x-1.png b/packages/desktop/icons/prod/ios/AppIcon-29x29@2x-1.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-29x29@2x-1.png rename to packages/desktop/icons/prod/ios/AppIcon-29x29@2x-1.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-29x29@2x.png b/packages/desktop/icons/prod/ios/AppIcon-29x29@2x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-29x29@2x.png rename to packages/desktop/icons/prod/ios/AppIcon-29x29@2x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-29x29@3x.png b/packages/desktop/icons/prod/ios/AppIcon-29x29@3x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-29x29@3x.png rename to packages/desktop/icons/prod/ios/AppIcon-29x29@3x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-40x40@1x.png b/packages/desktop/icons/prod/ios/AppIcon-40x40@1x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-40x40@1x.png rename to packages/desktop/icons/prod/ios/AppIcon-40x40@1x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-40x40@2x-1.png b/packages/desktop/icons/prod/ios/AppIcon-40x40@2x-1.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-40x40@2x-1.png rename to packages/desktop/icons/prod/ios/AppIcon-40x40@2x-1.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-40x40@2x.png b/packages/desktop/icons/prod/ios/AppIcon-40x40@2x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-40x40@2x.png rename to packages/desktop/icons/prod/ios/AppIcon-40x40@2x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-40x40@3x.png b/packages/desktop/icons/prod/ios/AppIcon-40x40@3x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-40x40@3x.png rename to packages/desktop/icons/prod/ios/AppIcon-40x40@3x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-512@2x.png b/packages/desktop/icons/prod/ios/AppIcon-512@2x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-512@2x.png rename to packages/desktop/icons/prod/ios/AppIcon-512@2x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-60x60@2x.png b/packages/desktop/icons/prod/ios/AppIcon-60x60@2x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-60x60@2x.png rename to packages/desktop/icons/prod/ios/AppIcon-60x60@2x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-60x60@3x.png b/packages/desktop/icons/prod/ios/AppIcon-60x60@3x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-60x60@3x.png rename to packages/desktop/icons/prod/ios/AppIcon-60x60@3x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-76x76@1x.png b/packages/desktop/icons/prod/ios/AppIcon-76x76@1x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-76x76@1x.png rename to packages/desktop/icons/prod/ios/AppIcon-76x76@1x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-76x76@2x.png b/packages/desktop/icons/prod/ios/AppIcon-76x76@2x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-76x76@2x.png rename to packages/desktop/icons/prod/ios/AppIcon-76x76@2x.png diff --git a/packages/desktop-electron/icons/prod/ios/AppIcon-83.5x83.5@2x.png b/packages/desktop/icons/prod/ios/AppIcon-83.5x83.5@2x.png similarity index 100% rename from packages/desktop-electron/icons/prod/ios/AppIcon-83.5x83.5@2x.png rename to packages/desktop/icons/prod/ios/AppIcon-83.5x83.5@2x.png diff --git a/packages/desktop/index.html b/packages/desktop/index.html deleted file mode 100644 index ce2775a704..0000000000 --- a/packages/desktop/index.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - OpenCode - - - - - - - - - - - - - -
-